Files
accounted/lib/salary/__tests__/book-run.test.ts
T
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

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

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00

345 lines
12 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(),
}))
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
// The pre-booking claim check stays real (zero queries without linked lines,
// one queued expense_claims read with them); only the settle RPC is mocked.
vi.mock('@/lib/salary/expense-claim-lines', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/expense-claim-lines')>(
'@/lib/salary/expense-claim-lines',
)
return { ...actual, settleExpenseClaimsForBookedRun: vi.fn() }
})
import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { settleExpenseClaimsForBookedRun } from '@/lib/salary/expense-claim-lines'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { refreshRunYtd } from '@/lib/salary/ytd'
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,
calculation_params: { slpRate: 0.2426 },
...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'])
// Booking is the last chance to correct the payslip's Ackumulerat block
// before the run becomes immutable.
expect(refreshRunYtd).toHaveBeenCalledWith(expect.anything(), {
companyId: 'company-1',
salaryRunId: 'run-1',
})
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(createSalaryRunEntries).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ calculation_params: { slpRate: 0.2426 } }),
)
})
})
describe('bookPaidSalaryRun: utlägg repaid with the salary (#2331)', () => {
const claimLine = (claimId: string, amount: number) => ({
item_type: 'expense_reimbursement',
amount,
account_number: '2820',
is_net_deduction: false,
is_gross_deduction: false,
source_expense_claim_id: claimId,
})
beforeEach(() => {
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({
ok: true,
data: { claim_count: 1, already_settled: 0, total_sek: 500, batches: [] },
})
})
it('refuses to post anything when a linked claim is no longer open', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'paid', employee_id: 'e1', amount_sek: 500 }] }, // paid by bank meanwhile
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN')
expect(result.details).toEqual({ claims: [{ claim_id: 'claim-1', reason: 'not_open' }] })
}
expect(createSalaryRunEntries).not.toHaveBeenCalled()
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts the verifikat, books the run, then settles the claims against it', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: '500.00' }] },
{ data: { id: 'run-1', status: 'booked' } },
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledWith(expect.anything(), {
companyId: 'company-1',
userId: 'user-1',
salaryRunId: 'run-1',
})
expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'salary_run.booked' }))
})
it('keeps the booking and logs loudly when the settle step fails after posting', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 500 }] },
{ data: { id: 'run-1', status: 'booked' } },
])
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({ ok: false, code: 'SETTLE_FAILED', detail: 'boom' })
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
const logError = (log as unknown as { error: ReturnType<typeof vi.fn> }).error
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('NOT settled'),
expect.any(Error),
expect.objectContaining({ salaryRunId: 'run-1', code: 'SETTLE_FAILED' }),
)
})
it('never touches the settle step for a run without linked lines', 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)
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts a run that only repays utlägg (gross 0, net > 0) instead of treating it as a nollkörning', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_gross: 0, total_tax: 0, total_net: 800, total_avgifter: 0 }) },
{
data: [
makeSre({
gross_salary: 0,
tax_withheld: 0,
net_salary: 800,
avgifter_amount: 0,
line_items: [claimLine('claim-1', 800)],
}),
],
},
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 800 }] },
{ 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.nollkorning).toBe(false)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledTimes(1)
})
})