* 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>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
/**
|
|
* Utlägg on the payslip (#2331): lib/salary/expense-claim-lines.ts.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import {
|
|
addOpenExpenseClaimsToPayslip,
|
|
assertLinkedExpenseClaimsOpen,
|
|
findPayslipLineForClaim,
|
|
listOpenExpenseClaimsForEmployee,
|
|
rosterHasLinkedExpenseClaims,
|
|
settleExpenseClaimsForBookedRun,
|
|
} from '../expense-claim-lines'
|
|
|
|
const COMPANY = 'company-1'
|
|
const RUN = 'run-1'
|
|
const EMPLOYEE = 'emp-1'
|
|
|
|
const { supabase, enqueue, enqueueMany, reset, findCall, findCalls } = createQueuedMockSupabase()
|
|
const sb = supabase as never
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
})
|
|
|
|
describe('listOpenExpenseClaimsForEmployee', () => {
|
|
it('returns the registered claims that are not yet on a payslip line, amounts as numbers', async () => {
|
|
enqueueMany([
|
|
{
|
|
data: [
|
|
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
|
|
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
|
|
],
|
|
},
|
|
{ data: [{ source_expense_claim_id: 'c-a' }] }, // c-a already scheduled on another draft
|
|
])
|
|
|
|
const open = await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)
|
|
|
|
expect(open).toEqual([
|
|
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
|
|
])
|
|
expect(findCall('salary_line_items', 'in')).toEqual(['source_expense_claim_id', ['c-a', 'c-b']])
|
|
})
|
|
|
|
it('does not look for scheduled lines when the employee has no registered claims', async () => {
|
|
enqueue({ data: [] })
|
|
expect(await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)).toEqual([])
|
|
expect(findCalls('salary_line_items', 'select')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('addOpenExpenseClaimsToPayslip', () => {
|
|
it('refuses once the run has left draft (same gate as the other line commands)', async () => {
|
|
enqueue({ data: { id: RUN, status: 'review' } })
|
|
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
|
|
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_LINE_NOT_DRAFT' })
|
|
expect(findCalls('salary_line_items', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('refuses an employee who is not on the run', async () => {
|
|
enqueueMany([{ data: { id: RUN, status: 'draft' } }, { data: null }])
|
|
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
|
|
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
|
|
})
|
|
|
|
it('answers SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS when nothing is left to add', async () => {
|
|
enqueueMany([
|
|
{ data: { id: RUN, status: 'draft' } },
|
|
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
|
|
{ data: [] }, // no registered claims
|
|
])
|
|
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
|
|
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' })
|
|
})
|
|
|
|
it('inserts one tax-free expense_reimbursement line per claim, linked, with the claim account and amount', async () => {
|
|
enqueueMany([
|
|
{ data: { id: RUN, status: 'draft' } },
|
|
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
|
|
{
|
|
data: [
|
|
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
|
|
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
|
|
],
|
|
},
|
|
{ data: [] }, // nothing scheduled elsewhere
|
|
{ data: [{ id: 'li-1', source_expense_claim_id: 'c-a' }, { id: 'li-2', source_expense_claim_id: 'c-b' }] },
|
|
])
|
|
|
|
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.claim_count).toBe(2)
|
|
expect(result.data.total_sek).toBe(1446.5)
|
|
expect(result.data.lines).toHaveLength(2)
|
|
}
|
|
const [rows] = findCall('salary_line_items', 'insert') as [Array<Record<string, unknown>>]
|
|
expect(rows).toEqual([
|
|
expect.objectContaining({
|
|
salary_run_employee_id: 'sre-1',
|
|
company_id: COMPANY,
|
|
item_type: 'expense_reimbursement',
|
|
description: 'Utlägg: Kabel (2026-06-01)',
|
|
amount: 250.5,
|
|
is_taxable: false,
|
|
is_avgift_basis: false,
|
|
is_vacation_basis: false,
|
|
is_gross_deduction: false,
|
|
is_net_deduction: false,
|
|
account_number: '2820',
|
|
sort_order: 500,
|
|
source_expense_claim_id: 'c-a',
|
|
}),
|
|
expect.objectContaining({ amount: 1196, sort_order: 501, source_expense_claim_id: 'c-b' }),
|
|
])
|
|
})
|
|
|
|
it('maps the partial unique index violation to EXPENSE_CLAIM_ALREADY_ON_PAYSLIP', async () => {
|
|
enqueueMany([
|
|
{ data: { id: RUN, status: 'draft' } },
|
|
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
|
|
{ data: [{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: 100, liability_account: '2820' }] },
|
|
{ data: [] },
|
|
{ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint' } },
|
|
])
|
|
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
|
|
expect(result).toMatchObject({ ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' })
|
|
})
|
|
})
|
|
|
|
describe('findPayslipLineForClaim', () => {
|
|
it('returns null when the claim is on no payslip', async () => {
|
|
enqueue({ data: null })
|
|
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toBeNull()
|
|
})
|
|
|
|
it('flattens the line -> run employee -> run embed', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'li-1',
|
|
salary_run_employee: {
|
|
salary_run_id: RUN,
|
|
salary_run: { id: RUN, status: 'review', period_year: 2026, period_month: 6 },
|
|
},
|
|
},
|
|
})
|
|
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toEqual({
|
|
line_id: 'li-1',
|
|
salary_run_id: RUN,
|
|
run_status: 'review',
|
|
period_year: 2026,
|
|
period_month: 6,
|
|
})
|
|
expect(findCall('salary_line_items', 'eq')).toEqual(['company_id', COMPANY])
|
|
})
|
|
})
|
|
|
|
describe('assertLinkedExpenseClaimsOpen', () => {
|
|
const roster = (lines: Array<Record<string, unknown>>) => [{ employee_id: EMPLOYEE, line_items: lines }]
|
|
|
|
it('is a no-op without linked lines: no query at all', async () => {
|
|
const result = await assertLinkedExpenseClaimsOpen(sb, COMPANY, roster([{ item_type: 'monthly_salary', amount: 30000 }]))
|
|
expect(result).toEqual({ ok: true, claim_count: 0 })
|
|
expect(findCalls('expense_claims', 'select')).toEqual([])
|
|
expect(rosterHasLinkedExpenseClaims(roster([{ item_type: 'monthly_salary' }]))).toBe(false)
|
|
})
|
|
|
|
it('passes when every linked claim is registered for the right employee at the line amount', async () => {
|
|
enqueue({ data: [{ id: 'c-a', status: 'registered', employee_id: EMPLOYEE, amount_sek: '250.50' }] })
|
|
const result = await assertLinkedExpenseClaimsOpen(
|
|
sb,
|
|
COMPANY,
|
|
roster([{ item_type: 'expense_reimbursement', amount: 250.5, source_expense_claim_id: 'c-a' }]),
|
|
)
|
|
expect(result).toEqual({ ok: true, claim_count: 1 })
|
|
expect(rosterHasLinkedExpenseClaims(roster([{ source_expense_claim_id: 'c-a' }]))).toBe(true)
|
|
})
|
|
|
|
it('names every problem: paid, missing, wrong employee, drifted amount', async () => {
|
|
enqueue({
|
|
data: [
|
|
{ id: 'c-paid', status: 'paid', employee_id: EMPLOYEE, amount_sek: 100 },
|
|
{ id: 'c-other', status: 'registered', employee_id: 'emp-2', amount_sek: 100 },
|
|
{ id: 'c-drift', status: 'registered', employee_id: EMPLOYEE, amount_sek: 99 },
|
|
],
|
|
})
|
|
const result = await assertLinkedExpenseClaimsOpen(
|
|
sb,
|
|
COMPANY,
|
|
roster([
|
|
{ amount: 100, source_expense_claim_id: 'c-paid' },
|
|
{ amount: 100, source_expense_claim_id: 'c-gone' },
|
|
{ amount: 100, source_expense_claim_id: 'c-other' },
|
|
{ amount: 100, source_expense_claim_id: 'c-drift' },
|
|
]),
|
|
)
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN',
|
|
details: {
|
|
claims: [
|
|
{ claim_id: 'c-paid', reason: 'not_open' },
|
|
{ claim_id: 'c-gone', reason: 'missing' },
|
|
{ claim_id: 'c-other', reason: 'employee_mismatch' },
|
|
{ claim_id: 'c-drift', reason: 'amount_mismatch' },
|
|
],
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('settleExpenseClaimsForBookedRun', () => {
|
|
const args = { companyId: COMPANY, userId: 'user-1', salaryRunId: RUN }
|
|
|
|
it('calls the RPC with the run and maps the batches', async () => {
|
|
enqueue({
|
|
data: {
|
|
ok: true,
|
|
claim_count: 2,
|
|
already_settled: 0,
|
|
total_sek: '1446.50',
|
|
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: '1446.50', claim_count: 2 }],
|
|
},
|
|
})
|
|
const result = await settleExpenseClaimsForBookedRun(sb, args)
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
data: {
|
|
claim_count: 2,
|
|
already_settled: 0,
|
|
total_sek: 1446.5,
|
|
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: 1446.5, claim_count: 2 }],
|
|
},
|
|
})
|
|
expect(supabase.rpc).toHaveBeenCalledWith('settle_expense_claims_via_salary_run', {
|
|
p_company_id: COMPANY,
|
|
p_salary_run_id: RUN,
|
|
p_user_id: 'user-1',
|
|
})
|
|
})
|
|
|
|
it('echoes an RPC refusal code with its details', async () => {
|
|
enqueue({ data: { ok: false, code: 'CLAIM_NOT_OPEN', details: { claim_id: 'c-a' } } })
|
|
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
|
|
ok: false,
|
|
code: 'CLAIM_NOT_OPEN',
|
|
detail: '{"claim_id":"c-a"}',
|
|
})
|
|
})
|
|
|
|
it('reports a database error as SETTLE_FAILED with the message', async () => {
|
|
enqueue({ data: null, error: { message: 'connection reset' } })
|
|
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
|
|
ok: false,
|
|
code: 'SETTLE_FAILED',
|
|
detail: 'connection reset',
|
|
})
|
|
})
|
|
})
|