Files
accounted/lib/pending-operations/__tests__/payroll-executors.test.ts
T
MattssonandClaude Fable 5.1 b07efcafd4 fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)
* fix(payroll): require jamkning valid_to on every write path (#2058)

A jamkningsbeslut saved through the v1 API or MCP with a percentage and a
start date but no end date was stored and returned 200, yet the engine
(isJamkningValid) never applies a beslut without both dates: the payslip
and the AGI carried the table tax while the caller believed the beslut
was live.

One shared validator (lib/salary/jamkning-rules.ts) now requires both
dates whenever a percentage is set and checks their ordering. Every write
path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST
and PATCH routes, the v1 PATCH route (its private copy is deleted), the
MCP create and update executors in employee-commands, and the MCP update
tool preflights the merged row at staging time so the agent sees the
error before approval. The update paths keep the existing touched gate, so
legacy rows stored without valid_to stay editable in unrelated ways.

The MCP tool descriptions state that both dates are required for the
beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing
rows (percentage set, valid_to null) per company, read-only; setting an
end date or clearing the beslut is decided per company since either
changes the next payslip.

Declined: defaulting valid_to to 31 December of the from-year. It matches
most beslut but silently changes withholding on rows that today do
nothing.

Closes #2058

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB

* fix(payroll): keep the jamkning PR inside the type and tools/list budgets

CI on the first push failed on two ratchets this PR itself tripped:

- Typecheck ratchet: the three staging tests added here reused the
  untyped 'agent_chat' actor literal the file already carried, which
  raised that file's error count above its baseline. They now pass
  { type: 'user' }.
- tools/list payload budget: the first jamkning field descriptions on
  gnubok_create_employee and gnubok_update_employee pushed the projected
  catalog to 60 113 tokens against the 60 000 ceiling. The percentage
  fields keep a one-line "needs both dates or never applied" note; the
  date fields drop theirs.

Also acts on the compliance swarm's GDPR Art.32 note: the read-only
lister no longer selects employee names at all (the employee id is what
the per-company decision needs), so the script touches no PII.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161fHpCX3rnWtidwwdGfCdB

* docs(mcp): say the jamkning percentage is rejected without both dates

CodeRabbit on #2240: "never applied" described the pre-fix engine
behaviour; the contract now is that a create or update with a percentage
and a missing date is rejected before staging. Same length, so the
tools/list payload budget is unchanged. The concurrency finding is
tracked in #2256 instead of this PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): keep tools/list under budget after proforma landed on main

After merging main (#2254 proforma fields) the projected tools/list
measured 60 010 tokens against the 60 000 ceiling with this PR's two
jamkning field notes. Per the budget test's own rule, demote a read tool
instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes
search-only. Versions exist only once a report is rendered for signing
or filing, which is the same switched-off iXBRL path as its sibling
gnubok_get_arsredovisning_filing_status, already search-only since
2026-09-02. Still reachable via gnubok_call_tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:22:41 +02:00

949 lines
36 KiB
TypeScript

/**
* Executor tests for the payroll gap-closure pending operations:
* update_payslip_line + register_absence (1.7).
*
* Executors are private to commit.ts and reached through
* commitPendingOperation (same pattern as dimension-value-executor.test.ts).
* Staging-side coverage lives in
* extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
const mockCloseYear = vi.fn()
vi.mock('@/lib/salary/semesterberedning', () => ({
previewVacationYearClose: vi.fn(),
commitVacationYearClose: (...a: unknown[]) => mockCloseYear(...a),
}))
const mockAdvanceAndBook = vi.fn()
vi.mock('@/lib/salary/book-run', () => ({
advanceAndBookSalaryRun: (...a: unknown[]) => mockAdvanceAndBook(...a),
bookPaidSalaryRun: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'update_payslip_line',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-07-13T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-13T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: update_payslip_line', () => {
const LINE_ROW = {
id: 'line-1',
salary_run_employee_id: 'sre-1',
company_id: 'company-1',
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '',
updated_at: '',
salary_run_employee: { salary_run_id: 'run-1' },
}
it('applies the patch 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: LINE_ROW }) // service loadLineInRun
enqueue({ data: { ...LINE_ROW, amount: 5500, salary_run_employee: undefined } }) // update
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: { amount: 5500 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
salary_line_item_id: 'line-1',
salary_run_id: 'run-1',
amount: 5500,
})
})
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: 'approved' } }) // draft gate trips
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: { amount: 5500 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/utkast/)
})
it('rejects an empty patch 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({
params: { salary_run_id: 'run-1', salary_line_item_id: 'line-1', patch: {} },
})
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: 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: update_salary_run', () => {
const RUN_ROW = {
id: 'run-1',
status: 'draft',
period_year: 2026,
period_month: 3,
payment_date: '2026-03-25',
voucher_series: 'L',
notes: null,
}
it('applies the header patch through the shared service (happy path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: RUN_ROW }) // service draft gate + current values
enqueue({ data: { ...RUN_ROW, payment_date: '2026-03-23' } }) // optimistic-locked update
enqueue({ data: null, error: null }) // roster calculation_breakdown clear
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-03-23' } },
})
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',
payment_date: '2026-03-23',
changes: { payment_date: '2026-03-23' },
})
})
it('re-clears breakdowns when retried with the already-applied date (partial-failure retry)', async () => {
// After a partial failure (header committed, clear failed) a retry sees
// the new date as current. The clear must run anyway: it is gated on the
// date being SUPPLIED, never on it differing from the stored value.
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ...RUN_ROW, payment_date: '2026-03-23' } }) // run already carries the new date
enqueue({ data: { ...RUN_ROW, payment_date: '2026-03-23' } }) // header update no-ops successfully
enqueue({ data: null, error: null }) // roster calculation_breakdown clear
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-03-23' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(findCall('salary_run_employees', 'update')).toEqual([{ calculation_breakdown: null }])
})
it('commits a payment_date in the month after the period (lön i efterskott, #2191)', async () => {
// The AGI redovisningsperiod follows payment_date, so a March run paid
// in April is declared for April; no period guard refuses it.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: RUN_ROW }) // draft gate passes; period is 2026-03
enqueue({ data: { ...RUN_ROW, payment_date: '2026-04-05' } }) // optimistic-locked update
enqueue({ data: null, error: null }) // roster calculation_breakdown clear
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-04-05' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ payment_date: '2026-04-05' })
})
it('fails when the calculation-invalidation clear errors (guard is not best-effort)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: RUN_ROW }) // service draft gate + current values
enqueue({ data: { ...RUN_ROW, payment_date: '2026-03-23' } }) // optimistic-locked update
enqueue({ data: null, error: { message: 'connection reset' } }) // breakdown clear fails
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-03-23' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
})
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: { ...RUN_ROW, status: 'review' } }) // draft gate trips
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-03-23' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/utkast/)
expect(result.http_status).toBe(400)
})
it('fails cleanly when the optimistic lock loses the race (update matches no row)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: RUN_ROW }) // draft gate passes
enqueue({ data: null }) // status flipped mid-flight: locked update hits nothing
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { payment_date: '2026-03-23' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/utkast/)
})
it('auto-rejects with 404 when the run does not exist for this company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null }) // company-scoped lookup misses
enqueue({ data: null, error: null }) // finalize (rejected)
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-foreign', patch: { payment_date: '2026-03-23' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// 404s are auto-rejected by the dispatcher (the run is gone; re-stage).
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
})
it('rejects a missing patch 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: 'update_salary_run',
params: { salary_run_id: 'run-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
it('rejects an invalid voucher_series with 400 before any write', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize (failed): validation trips pre-query
const op = makePendingOp({
operation_type: 'update_salary_run',
params: { salary_run_id: 'run-1', patch: { voucher_series: 'AB' } },
})
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()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee
enqueue({
data: [
{ id: 'a1', absence_date: '2026-03-02', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
{ id: 'a2', absence_date: '2026-03-03', absence_type: 'sick', hours: 8, notes: null, salary_run_employee_id: null, created_at: '', updated_at: '' },
],
}) // bulk upsert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'register_absence',
params: {
employee_id: 'emp-1',
from: '2026-03-02',
to: '2026-03-03',
absence_type: 'sick',
hours_per_day: 8,
notes: null,
include_weekends: false,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_id: 'emp-1',
absence_type: 'sick',
day_count: 2,
})
})
it('maps the 24h-cap trigger to a clean 409 failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
enqueue({ data: null, error: { code: '23514', message: 'Total tid över 24h' } }) // upsert trips trigger
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'register_absence',
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-02', absence_type: 'sick' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// 409 is a client-state conflict: the dispatcher rejects the op (fix the
// day's hours and re-stage) rather than marking it transiently failed.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('logs the PG details and persists a sanitized error_code when the upsert fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
enqueue({
data: null,
error: {
code: '42501',
message:
'new row violates row-level security policy for table "salary_absence_franvaro_audit"',
},
}) // upsert denied (the franvaro audit-trigger bug)
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'register_absence',
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-02', absence_type: 'parental' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(500)
expect(result.code).toBe('DB_PERMISSION_DENIED')
// The op row keeps the structured code so the failure mode is
// diagnosable later, but never the raw PG text.
const updates = findCalls('pending_operations', 'update')
const finalize = updates[updates.length - 1]![0] as { result_data?: Record<string, unknown> }
expect(finalize.result_data).toMatchObject({
error_code: 'DB_PERMISSION_DENIED',
http_status: 500,
})
expect(JSON.stringify(finalize.result_data)).not.toContain('row-level security')
// The raw PG message goes to the log: it is persisted nowhere else.
const logged = consoleError.mock.calls.map((c) => c.join(' ')).join('\n')
expect(logged).toContain('register_absence commit failed')
expect(logged).toContain('row-level security')
} finally {
consoleError.mockRestore()
}
})
})
describe('commitPendingOperation: book_salary_run', () => {
it('books through the shared advance-walk service (happy path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize
mockAdvanceAndBook.mockResolvedValue({
ok: true,
data: {
run: { period_year: 2026, period_month: 6, status: 'booked' },
entryIds: ['je-1', 'je-2'],
nollkorning: false,
warnings: ['Anna Svensson: E-post saknas, lönebesked kan inte skickas'],
},
})
const op = makePendingOp({
operation_type: 'book_salary_run',
risk_level: 'high',
params: { salary_run_id: 'run-1' },
})
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',
status: 'booked',
period: '2026-06',
journal_entry_ids: ['je-1', 'je-2'],
})
expect(mockAdvanceAndBook).toHaveBeenCalledWith(
supabase,
expect.objectContaining({ companyId: 'company-1', userId: 'user-1', salaryRunId: 'run-1' }),
)
})
it('rejects cleanly when the run was already booked between staging and approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize (failed)
mockAdvanceAndBook.mockResolvedValue({ ok: false, code: 'SALARY_RUN_ALREADY_BOOKED' })
const op = makePendingOp({
operation_type: 'book_salary_run',
risk_level: 'high',
params: { salary_run_id: 'run-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.error).toMatch(/redan bokförd/)
})
})
describe('commitPendingOperation: delete_absence', () => {
it('deletes the range through the shared service (happy path)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee
enqueue({ data: null, count: 3 }) // delete with count
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'delete_absence',
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_id: 'emp-1',
absence_type: 'sick',
deleted_count: 3,
})
})
it('fails cleanly for an unknown employee', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null }) // assertEmployee misses
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'delete_absence',
params: { employee_id: 'emp-x', from: '2026-03-02', to: '2026-03-06' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(result.error).toBeDefined()
})
it('logs the PG details and persists a sanitized error_code when the delete fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'emp-1' } }) // assertEmployee
enqueue({ data: null, error: { code: '57014', message: 'canceling statement due to statement timeout' } }) // delete fails
enqueue({ data: null, error: null }) // finalize (failed)
const op = makePendingOp({
operation_type: 'delete_absence',
params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.code).toBe('INTERNAL_ERROR')
const updates = findCalls('pending_operations', 'update')
const finalize = updates[updates.length - 1]![0] as { result_data?: Record<string, unknown> }
expect(finalize.result_data).toMatchObject({ error_code: 'INTERNAL_ERROR' })
const logged = consoleError.mock.calls.map((c) => c.join(' ')).join('\n')
expect(logged).toContain('delete_absence commit failed')
expect(logged).toContain('statement timeout')
} finally {
consoleError.mockRestore()
}
})
})
describe('commitPendingOperation: create_employee', () => {
it('inserts via the shared service with the pre-encrypted personnummer', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const encrypted = encryptPersonnummer('190001010000')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { entity_type: 'ab' } }) // getCompanyEntityType (company_settings)
enqueue({
data: {
id: 'emp-new',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
is_active: true,
},
}) // insert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'create_employee',
params: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer_encrypted: encrypted,
personnummer_last4: '0000',
employment_type: 'employee',
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_id: 'emp-new',
personnummer_masked: '19000101-XXXX',
})
// Result data never carries the plaintext or ciphertext.
expect(JSON.stringify(result.data)).not.toContain('190001010000')
expect(JSON.stringify(result.data)).not.toContain(encrypted)
})
it('rejects a jämkning percentage without an end date before inserting (#2058)', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const { supabase, enqueue, calls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { entity_type: 'ab' } }) // entity type
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'create_employee',
params: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer_encrypted: encryptPersonnummer('190001010000'),
personnummer_last4: '0000',
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/slutdatum/)
expect(calls.some((c) => c.table === 'employees' && c.method === 'insert')).toBe(false)
})
it('maps duplicate personnummer to a clean rejection', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { entity_type: 'ab' } }) // entity type
enqueue({
data: null,
error: { code: '23505', message: 'duplicate', constraint: 'employees_company_id_personnummer_key' },
}) // insert conflict
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'create_employee',
params: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer_encrypted: encryptPersonnummer('190001010000'),
personnummer_last4: '0000',
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
})
describe('commitPendingOperation: update_employee', () => {
it('applies the patch through merged-state validation (happy path)', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const encrypted = encryptPersonnummer('190001010000')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: null,
is_active: true,
},
}) // fetch existing
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
is_active: true,
},
}) // update
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: { employee_id: 'emp-1', patch: { monthly_salary: 38000 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ employee_id: 'emp-1' })
})
it('upserts opening balances atomically (set_employee_opening_balances)', async () => {
const CURRENT_YEAR = new Date().getFullYear()
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: [{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', employment_start: '2024-01-15', is_active: true }] }) // employees
enqueue({ data: [] }) // locks
enqueue({ data: [] }) // existing created_by lookup
enqueue({
data: [
{
id: 'ob-1',
employee_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: {},
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
created_at: '',
updated_at: '',
},
],
}) // upsert
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'set_employee_opening_balances',
params: {
items: [
{
employee_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
employee_count: 1,
employee_opening_balances_ids: ['ob-1'],
})
})
it('closes the vacation year through the shared service (vacation_year_close)', async () => {
mockCloseYear.mockResolvedValue({
ok: true,
data: {
closure_id: 'closure-1',
adjustment_entry_id: 'je-1',
report: { rows: [{ employee_id: 'emp-1' }], sek: { drift_2920: 8690.84 } },
},
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'vacation_year_close',
params: { vacation_year_start: '2025-01-01', book_adjustment: true },
risk_level: 'high',
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
vacation_year_closure_id: 'closure-1',
adjustment_entry_id: 'je-1',
employee_count: 1,
})
expect(mockCloseYear).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'2025-01-01',
{ bookAdjustment: true },
)
})
it('rejects a vacation_year_close replay cleanly', async () => {
mockCloseYear.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'vacation_year_close',
params: { vacation_year_start: '2025-01-01' },
risk_level: 'high',
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('rejects a jämkning percentage without an end date on the merged row (#2058)', async () => {
const { supabase, enqueue, calls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: null,
jamkning_valid_from: null,
jamkning_valid_to: null,
},
}) // fetch existing
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: {
employee_id: 'emp-1',
patch: { jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' },
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/slutdatum/)
expect(calls.some((c) => c.table === 'employees' && c.method === 'update')).toBe(false)
})
it('leaves a legacy row without valid_to editable in unrelated ways (touched gate)', async () => {
const { encryptPersonnummer } = await import('@/lib/salary/personnummer')
const encrypted = encryptPersonnummer('190001010000')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: encrypted,
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
is_active: true,
},
}) // fetch existing
enqueue({
data: { id: 'emp-1', first_name: 'Anna', last_name: 'Andersson', personnummer: encrypted, is_active: true },
}) // update
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: { employee_id: 'emp-1', patch: { monthly_salary: 38000 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
})
it('fails merged validation when clearing salary below zero-state', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vaxa_stod_eligible: false,
jamkning_percentage: null,
},
}) // fetch existing
enqueue({ data: null, error: null }) // finalize
const op = makePendingOp({
operation_type: 'update_employee',
params: { employee_id: 'emp-1', patch: { monthly_salary: 0 } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/Månadslön/)
})
})