* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion Close the last MCP-surface gaps for running payroll end-to-end via the connector (the v1 REST API already had the full chain): - gnubok_book_salary_run: stages a high-risk book operation; on approval the executor walks review -> approved -> paid -> booked via the new lib/salary/book-run.ts (extracted from the dashboard book route, which now calls the same core) and posts the immutable salary vouchers. - gnubok_delete_absence: staged inverse of gnubok_register_absence, reusing deleteAbsenceRange with a dry-run day-count preview. - Wire the missing payroll operation types into the Granskning label map (register_absence, update_payslip_line, employee ops, vacation_year_close had translations but fell back to humanized snake_case). - Update stale 'booking happens in the web UI' prose in tool descriptions, the payroll-monthly skill, and the workflow hint; payload-size ceiling 56K -> 57K per the documented bump protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run The op-type audit (pg-real) caught the exact bug class it exists for: book_salary_run and delete_absence were staged in code without the constraint-expansion migration, so every real staging INSERT would have failed with check_violation while dry_run previewed clean. Ships the documented widen (NOT VALID) + validate migration pair. Also fixes the strict-mode cast in book-run.ts that failed the production typecheck. Verified locally against supabase/postgres 15.8.1.060 with all migrations applied: op-type audit green, pg-real 692/693 (the one failure is the pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under TZ=UTC as in CI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
199 lines
6.4 KiB
TypeScript
199 lines
6.4 KiB
TypeScript
/**
|
|
* Tests for the shared salary-run booking orchestration (lib/salary/book-run.ts):
|
|
* the dashboard book route's extracted core plus the advance-walk used by the
|
|
* book_salary_run pending-operation executor (MCP gnubok_book_salary_run).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
vi.mock('@/lib/events', () => ({
|
|
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
|
|
}))
|
|
vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() }))
|
|
vi.mock('@/lib/salary/vacation-ledger', () => ({
|
|
syncVacationLedgerForEmployees: vi.fn(),
|
|
}))
|
|
|
|
import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run'
|
|
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
|
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
const log = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
child: vi.fn(),
|
|
} as never
|
|
|
|
const ARGS = { companyId: 'company-1', userId: 'user-1', salaryRunId: 'run-1', log }
|
|
|
|
const makeRun = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 'run-1',
|
|
company_id: 'company-1',
|
|
status: 'review',
|
|
period_year: 2026,
|
|
period_month: 6,
|
|
payment_date: '2026-06-25',
|
|
voucher_series: 'L',
|
|
total_gross: 30000,
|
|
total_tax: 7000,
|
|
total_net: 23000,
|
|
total_avgifter: 9426,
|
|
total_vacation_accrual: 0,
|
|
...overrides,
|
|
})
|
|
|
|
const makeSre = (overrides: Record<string, unknown> = {}) => ({
|
|
employee_id: 'e1',
|
|
gross_salary: 30000,
|
|
tax_withheld: 7000,
|
|
tax_withheld_override: null,
|
|
net_salary: 23000,
|
|
avgifter_amount: 9426,
|
|
avgifter_amount_override: null,
|
|
avgifter_rate: 0.3142,
|
|
vacation_accrual: 0,
|
|
vacation_accrual_avgifter: 0,
|
|
calculation_breakdown: { steps: [] },
|
|
line_items: [],
|
|
employee: {
|
|
first_name: 'Anna',
|
|
last_name: 'Svensson',
|
|
employment_type: 'employee',
|
|
default_dimensions: null,
|
|
f_skatt_status: 'a_skatt',
|
|
clearing_number: '8327',
|
|
bank_account_number: '123456789',
|
|
email: 'anna@example.se',
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(syncVacationLedgerForEmployees).mockResolvedValue({ ok: true } as never)
|
|
vi.mocked(createSalaryRunEntries).mockResolvedValue({
|
|
salaryEntry: { id: 'je-1' },
|
|
avgifterEntry: { id: 'je-2' },
|
|
vacationEntry: null,
|
|
pensionEntry: null,
|
|
} as never)
|
|
})
|
|
|
|
describe('advanceAndBookSalaryRun', () => {
|
|
it('refuses an already-booked run', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: makeRun({ status: 'booked' }) })
|
|
|
|
const result = await advanceAndBookSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('SALARY_RUN_ALREADY_BOOKED')
|
|
})
|
|
|
|
it('blocks a draft run whose roster lacks a calculation', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: makeRun({ status: 'draft' }) },
|
|
{ data: [makeSre({ calculation_breakdown: null })] },
|
|
])
|
|
|
|
const result = await advanceAndBookSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) {
|
|
expect(result.code).toBe('SALARY_RUN_NOT_CALCULATED')
|
|
expect(result.details?.employees).toEqual(['Anna Svensson'])
|
|
}
|
|
expect(createSalaryRunEntries).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('walks review → approved → paid → booked and surfaces bank-detail warnings', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: makeRun({ status: 'review' }) },
|
|
{ data: [makeSre({ employee: { ...makeSre().employee, clearing_number: null } })] },
|
|
{ data: { id: 'run-1' } }, // review → approved
|
|
{ data: { id: 'run-1' } }, // approved → paid
|
|
{ data: { id: 'run-1', status: 'booked' } }, // paid → booked
|
|
])
|
|
|
|
const result = await advanceAndBookSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
|
|
expect(result.data.nollkorning).toBe(false)
|
|
expect(result.data.warnings.some((w) => w.includes('Bankuppgifter saknas'))).toBe(true)
|
|
}
|
|
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
|
|
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'salary_run.approved' }),
|
|
)
|
|
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'salary_run.booked' }),
|
|
)
|
|
})
|
|
|
|
it('books a paid zero-total run as nollkörning without journal entries', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{
|
|
data: makeRun({
|
|
status: 'paid',
|
|
total_gross: 0,
|
|
total_tax: 0,
|
|
total_net: 0,
|
|
total_avgifter: 0,
|
|
}),
|
|
},
|
|
{ data: [] }, // empty roster
|
|
{ data: { id: 'run-1', status: 'booked' } }, // → booked
|
|
])
|
|
|
|
const result = await advanceAndBookSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.nollkorning).toBe(true)
|
|
expect(result.data.entryIds).toEqual([])
|
|
expect(result.data.warnings).toEqual([])
|
|
}
|
|
expect(createSalaryRunEntries).not.toHaveBeenCalled()
|
|
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'salary_run.booked' }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('bookPaidSalaryRun', () => {
|
|
it('requires the run to be in paid status', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null, error: { message: 'No rows' } }) // status filter misses
|
|
|
|
const result = await bookPaidSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) {
|
|
expect(result.code).toBe('SALARY_RUN_NOT_CALCULATED')
|
|
expect(result.details).toEqual({ reason: 'must_be_paid_status' })
|
|
}
|
|
})
|
|
|
|
it('books a paid run and returns the entry ids', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: makeRun({ status: 'paid' }) },
|
|
{ data: [makeSre()] },
|
|
{ data: { id: 'run-1', status: 'booked' } },
|
|
])
|
|
|
|
const result = await bookPaidSalaryRun(supabase as never, ARGS)
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
|
|
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|