4fce2d7b94
* 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>
399 lines
18 KiB
TypeScript
399 lines
18 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, it, expect } from 'vitest'
|
|
import type { PoolClient } from 'pg'
|
|
import { getPool, getClient, withUserContext } from './setup'
|
|
import { seedCompany, insertAuthUser, insertCompanyMember, insertPostedJournalEntry } from './fixtures'
|
|
|
|
// pg-real coverage for 20260906210300_utlagg_via_lon (+ 20260906210301):
|
|
// - the expense_reimbursement item type on salary_line_items
|
|
// - salary_line_items.source_expense_claim_id: tenant-scoped FK, ON DELETE
|
|
// RESTRICT (a referenced claim cannot be deleted by any path), one
|
|
// payslip line per claim
|
|
// - settle_expense_claims_via_salary_run: the payroll-side twin of
|
|
// create_expense_payout_batch (same batch table, same status flip, no
|
|
// verifikat of its own), idempotent, refuses anything not open
|
|
|
|
type SettleResult = {
|
|
ok: boolean
|
|
code?: string
|
|
details?: Record<string, unknown>
|
|
claim_count?: number
|
|
already_settled?: number
|
|
total_sek?: string | number
|
|
journal_entry_id?: string
|
|
batches?: Array<{ batch_id: string; employee_id: string; total_sek: string | number; claim_count: number }>
|
|
}
|
|
|
|
async function insertEmployee(companyId: string, userId: string, first = 'Anna'): Promise<string> {
|
|
const { rows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.employees
|
|
(company_id, user_id, first_name, last_name, personnummer, personnummer_last4,
|
|
employment_type, employment_start, employment_degree, salary_type)
|
|
VALUES ($1, $2, $3, 'Anställd', $4, $5, 'employee', '2026-01-01', 100, 'monthly')
|
|
RETURNING id`,
|
|
[companyId, userId, first, `19900101${String(Math.floor(1000 + Math.random() * 9000))}`, '1234'],
|
|
)
|
|
return rows[0].id
|
|
}
|
|
|
|
async function insertClaim(
|
|
companyId: string,
|
|
userId: string,
|
|
employeeId: string | null,
|
|
amountSek: number,
|
|
opts: { liability?: string; claimant?: string } = {},
|
|
): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.expense_claims
|
|
(id, company_id, user_id, employee_id, claimant_name, description, expense_date,
|
|
amount_sek, vat_sek, expense_account, liability_account, status)
|
|
VALUES ($1, $2, $3, $4, $5, 'Kabel', '2026-06-10', $6, 0, '5410', $7, 'registered')`,
|
|
[id, companyId, userId, employeeId, opts.claimant ?? 'Anna Anställd', amountSek, opts.liability ?? '2820'],
|
|
)
|
|
return id
|
|
}
|
|
|
|
/** One run per period and company (idx_salary_runs_period_unique): pass a month for a second run. */
|
|
async function insertRun(companyId: string, userId: string, status = 'draft', month = 6): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.salary_runs (id, company_id, user_id, period_year, period_month, payment_date, status)
|
|
VALUES ($1, $2, $3, 2026, $4, $5, $6)`,
|
|
[id, companyId, userId, month, `2026-${String(month).padStart(2, '0')}-25`, status],
|
|
)
|
|
return id
|
|
}
|
|
|
|
async function insertSre(runId: string, employeeId: string, companyId: string): Promise<string> {
|
|
const { rows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.salary_run_employees
|
|
(salary_run_id, employee_id, company_id, employment_degree, monthly_salary, salary_type)
|
|
VALUES ($1, $2, $3, 100, 30000, 'monthly')
|
|
RETURNING id`,
|
|
[runId, employeeId, companyId],
|
|
)
|
|
return rows[0].id
|
|
}
|
|
|
|
async function insertLine(
|
|
sreId: string,
|
|
companyId: string,
|
|
opts: { claimId?: string | null; amount: number; itemType?: string; account?: string },
|
|
): Promise<string> {
|
|
const { rows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.salary_line_items
|
|
(salary_run_employee_id, company_id, item_type, description, amount,
|
|
is_taxable, is_avgift_basis, is_vacation_basis, account_number, source_expense_claim_id)
|
|
VALUES ($1, $2, $3, 'Utlägg: Kabel', $4, false, false, false, $5, $6)
|
|
RETURNING id`,
|
|
[sreId, companyId, opts.itemType ?? 'expense_reimbursement', opts.amount, opts.account ?? '2820', opts.claimId ?? null],
|
|
)
|
|
return rows[0].id
|
|
}
|
|
|
|
/** Post the salary verifikat (2820 D / 1930 K) and flip the run to booked. */
|
|
async function bookRun(args: {
|
|
runId: string
|
|
companyId: string
|
|
userId: string
|
|
fiscalPeriodId: string
|
|
amount: number
|
|
}): Promise<string> {
|
|
const jeId = await insertPostedJournalEntry({
|
|
userId: args.userId,
|
|
companyId: args.companyId,
|
|
fiscalPeriodId: args.fiscalPeriodId,
|
|
entryDate: '2026-06-25',
|
|
description: 'Lön 2026-06',
|
|
voucherSeries: 'L',
|
|
voucherNumber: 1,
|
|
sourceType: 'salary_payment',
|
|
sourceId: args.runId,
|
|
lines: [
|
|
{ accountNumber: '2820', debitAmount: args.amount, creditAmount: 0 },
|
|
{ accountNumber: '1930', debitAmount: 0, creditAmount: args.amount },
|
|
],
|
|
})
|
|
await getPool().query(
|
|
`UPDATE public.salary_runs SET status = 'booked', salary_entry_id = $2 WHERE id = $1`,
|
|
[args.runId, jeId],
|
|
)
|
|
return jeId
|
|
}
|
|
|
|
async function settle(client: PoolClient, companyId: string, runId: string): Promise<SettleResult> {
|
|
const { rows } = await client.query<{ r: SettleResult }>(
|
|
`SELECT public.settle_expense_claims_via_salary_run($1, $2, NULL) AS r`,
|
|
[companyId, runId],
|
|
)
|
|
return rows[0].r
|
|
}
|
|
|
|
/** Like withUserContext but COMMITs, so a later call can observe the result. */
|
|
async function asUser<T>(userId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
const client = await getClient()
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
|
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
|
])
|
|
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
|
await client.query('SET LOCAL ROLE authenticated')
|
|
const result = await fn(client)
|
|
await client.query('COMMIT')
|
|
return result
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {})
|
|
throw err
|
|
} finally {
|
|
client.release()
|
|
}
|
|
}
|
|
|
|
async function claimState(claimIds: string[]) {
|
|
const { rows } = await getPool().query<{ id: string; status: string; payout_batch_id: string | null }>(
|
|
`SELECT id, status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[]) ORDER BY id`,
|
|
[claimIds],
|
|
)
|
|
return rows
|
|
}
|
|
|
|
async function ledgerCounts(companyId: string) {
|
|
const { rows } = await getPool().query<{ batches: string; payouts: string }>(
|
|
`SELECT
|
|
(SELECT count(*) FROM public.expense_payout_batches WHERE company_id = $1)::text AS batches,
|
|
(SELECT count(*) FROM public.journal_entries WHERE company_id = $1 AND source_type = 'expense_payout')::text AS payouts`,
|
|
[companyId],
|
|
)
|
|
return { batches: Number(rows[0].batches), payouts: Number(rows[0].payouts) }
|
|
}
|
|
|
|
describe('salary_line_items.source_expense_claim_id', () => {
|
|
it('accepts expense_reimbursement lines and keeps a claim on one payslip line at a time', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const employeeId = await insertEmployee(companyId, userId)
|
|
const claimId = await insertClaim(companyId, userId, employeeId, 250.5)
|
|
const runA = await insertRun(companyId, userId)
|
|
const runB = await insertRun(companyId, userId, 'draft', 7)
|
|
const sreA = await insertSre(runA, employeeId, companyId)
|
|
const sreB = await insertSre(runB, employeeId, companyId)
|
|
|
|
await insertLine(sreA, companyId, { claimId, amount: 250.5 })
|
|
await expect(insertLine(sreB, companyId, { claimId, amount: 250.5 })).rejects.toMatchObject({ code: '23505' })
|
|
// The unlinked form of the type is still fine (a manual tax-free line).
|
|
await expect(insertLine(sreB, companyId, { amount: 100 })).resolves.toBeTruthy()
|
|
})
|
|
|
|
it('binds the link to the line\'s own company (composite FK)', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
const employeeA = await insertEmployee(a.companyId, a.userId)
|
|
const employeeB = await insertEmployee(b.companyId, b.userId)
|
|
const foreignClaim = await insertClaim(b.companyId, b.userId, employeeB, 100)
|
|
const runA = await insertRun(a.companyId, a.userId)
|
|
const sreA = await insertSre(runA, employeeA, a.companyId)
|
|
|
|
await expect(insertLine(sreA, a.companyId, { claimId: foreignClaim, amount: 100 })).rejects.toMatchObject({
|
|
code: '23503',
|
|
})
|
|
})
|
|
|
|
it('refuses to delete a claim that a booked run\'s payslip line references (RESTRICT, 23503)', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const employeeId = await insertEmployee(companyId, userId)
|
|
const claimId = await insertClaim(companyId, userId, employeeId, 100)
|
|
const runId = await insertRun(companyId, userId, 'paid')
|
|
const sreId = await insertSre(runId, employeeId, companyId)
|
|
const lineId = await insertLine(sreId, companyId, { claimId, amount: 100 })
|
|
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
|
|
|
|
// Superuser over the pool: no RLS, no service in front. The FK alone
|
|
// must hold, or a script could pull the line from under the verifikat.
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
|
|
).rejects.toMatchObject({ code: '23503' })
|
|
const { rows } = await getPool().query(`SELECT id FROM public.salary_line_items WHERE id = $1`, [lineId])
|
|
expect(rows).toHaveLength(1)
|
|
})
|
|
|
|
it('refuses the delete on a draft run too; the app path removes the line first, then the claim', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const employeeId = await insertEmployee(companyId, userId)
|
|
const claimId = await insertClaim(companyId, userId, employeeId, 100)
|
|
const runId = await insertRun(companyId, userId)
|
|
const sreId = await insertSre(runId, employeeId, companyId)
|
|
const lineId = await insertLine(sreId, companyId, { claimId, amount: 100 })
|
|
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
|
|
).rejects.toMatchObject({ code: '23503' })
|
|
|
|
// deleteExpenseClaim's draft order: line, then (storno, then) claim.
|
|
await getPool().query(`DELETE FROM public.salary_line_items WHERE id = $1 AND company_id = $2`, [lineId, companyId])
|
|
await getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId])
|
|
const { rows } = await getPool().query(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId])
|
|
expect(rows).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('settle_expense_claims_via_salary_run', () => {
|
|
it('marks the claims paid with one batch per person pointing at the salary verifikat, no second verifikat', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const anna = await insertEmployee(companyId, userId, 'Anna')
|
|
const bo = await insertEmployee(companyId, userId, 'Bo')
|
|
const a1 = await insertClaim(companyId, userId, anna, 250.5)
|
|
const a2 = await insertClaim(companyId, userId, anna, 1196)
|
|
const b1 = await insertClaim(companyId, userId, bo, 80, { claimant: 'Bo Anställd' })
|
|
const runId = await insertRun(companyId, userId, 'paid')
|
|
const sreAnna = await insertSre(runId, anna, companyId)
|
|
const sreBo = await insertSre(runId, bo, companyId)
|
|
await insertLine(sreAnna, companyId, { claimId: a1, amount: 250.5 })
|
|
await insertLine(sreAnna, companyId, { claimId: a2, amount: 1196 })
|
|
await insertLine(sreBo, companyId, { claimId: b1, amount: 80 })
|
|
const jeId = await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 1526.5 })
|
|
|
|
const r = await asUser(userId, (c) => settle(c, companyId, runId))
|
|
|
|
expect(r.ok).toBe(true)
|
|
expect(r.claim_count).toBe(3)
|
|
expect(r.already_settled).toBe(0)
|
|
expect(Number(r.total_sek)).toBe(1526.5)
|
|
expect(r.journal_entry_id).toBe(jeId)
|
|
expect(r.batches).toHaveLength(2)
|
|
|
|
const { rows: batches } = await getPool().query<{
|
|
employee_id: string
|
|
claimant_name: string
|
|
payout_date: string
|
|
cash_account: string
|
|
liability_account: string
|
|
total_sek: string
|
|
journal_entry_id: string
|
|
notes: string
|
|
}>(
|
|
`SELECT b.employee_id, b.claimant_name, b.payout_date::text, b.cash_account, b.liability_account,
|
|
b.total_sek::text, b.journal_entry_id, b.notes
|
|
FROM public.expense_payout_batches b WHERE b.company_id = $1 ORDER BY b.total_sek`,
|
|
[companyId],
|
|
)
|
|
expect(batches).toEqual([
|
|
{
|
|
employee_id: bo,
|
|
claimant_name: 'Bo Anställd',
|
|
payout_date: '2026-06-25',
|
|
cash_account: '1930',
|
|
liability_account: '2820',
|
|
total_sek: '80.00',
|
|
journal_entry_id: jeId,
|
|
notes: 'Utbetalt via lön 2026-06',
|
|
},
|
|
{
|
|
employee_id: anna,
|
|
claimant_name: 'Anna Anställd',
|
|
payout_date: '2026-06-25',
|
|
cash_account: '1930',
|
|
liability_account: '2820',
|
|
total_sek: '1446.50',
|
|
journal_entry_id: jeId,
|
|
notes: 'Utbetalt via lön 2026-06',
|
|
},
|
|
])
|
|
|
|
const claims = await claimState([a1, a2, b1])
|
|
expect(claims.map((c) => c.status)).toEqual(['paid', 'paid', 'paid'])
|
|
const annaBatch = batches[1]
|
|
const { rows: annaClaims } = await getPool().query<{ payout_batch_id: string; bid: string }>(
|
|
`SELECT ec.payout_batch_id, b.id AS bid FROM public.expense_claims ec
|
|
JOIN public.expense_payout_batches b ON b.id = ec.payout_batch_id
|
|
WHERE ec.id = ANY($1::uuid[]) AND b.employee_id = $2`,
|
|
[[a1, a2], anna],
|
|
)
|
|
expect(annaClaims).toHaveLength(2)
|
|
expect(annaBatch.employee_id).toBe(anna)
|
|
|
|
// The salary verifikat IS the payout: nothing else was posted.
|
|
expect(await ledgerCounts(companyId)).toEqual({ batches: 2, payouts: 0 })
|
|
})
|
|
|
|
it('is idempotent: a retry counts the claims as already settled and adds no batch', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const anna = await insertEmployee(companyId, userId)
|
|
const a1 = await insertClaim(companyId, userId, anna, 100)
|
|
const runId = await insertRun(companyId, userId, 'paid')
|
|
const sre = await insertSre(runId, anna, companyId)
|
|
await insertLine(sre, companyId, { claimId: a1, amount: 100 })
|
|
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
|
|
|
|
const first = await asUser(userId, (c) => settle(c, companyId, runId))
|
|
expect(first).toMatchObject({ ok: true, claim_count: 1, already_settled: 0 })
|
|
const second = await asUser(userId, (c) => settle(c, companyId, runId))
|
|
expect(second).toMatchObject({ ok: true, claim_count: 0, already_settled: 1 })
|
|
expect(second.batches).toEqual([])
|
|
expect(await ledgerCounts(companyId)).toEqual({ batches: 1, payouts: 0 })
|
|
})
|
|
|
|
it('refuses a run that is not booked, a claim paid elsewhere, a drifted amount, and non-writers', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const anna = await insertEmployee(companyId, userId)
|
|
const a1 = await insertClaim(companyId, userId, anna, 100)
|
|
const a2 = await insertClaim(companyId, userId, anna, 200)
|
|
const runId = await insertRun(companyId, userId, 'paid')
|
|
const sre = await insertSre(runId, anna, companyId)
|
|
await insertLine(sre, companyId, { claimId: a1, amount: 100 })
|
|
const line2 = await insertLine(sre, companyId, { claimId: a2, amount: 200 })
|
|
|
|
// Not booked yet: nothing to point the batch at.
|
|
const notBooked = await withUserContext(userId, (c) => settle(c, companyId, runId))
|
|
expect(notBooked).toMatchObject({ ok: false, code: 'SALARY_RUN_NOT_BOOKED', details: { status: 'paid' } })
|
|
|
|
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 300 })
|
|
|
|
// Viewer and stranger: FORBIDDEN, ledger untouched.
|
|
const viewer = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
|
const stranger = await insertAuthUser()
|
|
for (const uid of [viewer, stranger]) {
|
|
const r = await withUserContext(uid, (c) => settle(c, companyId, runId))
|
|
expect(r).toMatchObject({ ok: false, code: 'FORBIDDEN' })
|
|
}
|
|
expect(await ledgerCounts(companyId)).toEqual({ batches: 0, payouts: 0 })
|
|
|
|
// Amount drift between line and claim: refused before any write.
|
|
await getPool().query(`UPDATE public.salary_line_items SET amount = 199 WHERE id = $1`, [line2])
|
|
const drift = await withUserContext(userId, (c) => settle(c, companyId, runId))
|
|
expect(drift).toMatchObject({ ok: false, code: 'CLAIM_AMOUNT_MISMATCH', details: { claim_id: a2 } })
|
|
await getPool().query(`UPDATE public.salary_line_items SET amount = 200 WHERE id = $1`, [line2])
|
|
|
|
// a2 paid by some other batch in the meantime: the salary verifikat
|
|
// already carries its 2820 debit, so this is a refusal, not a skip.
|
|
const otherBatch = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.expense_payout_batches
|
|
(id, company_id, user_id, employee_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
|
|
VALUES ($1, $2, $3, $4, 'Anna Anställd', '2026-06-20', '1930', '2820', 200)`,
|
|
[otherBatch, companyId, userId, anna],
|
|
)
|
|
await getPool().query(
|
|
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $2 WHERE id = $1`,
|
|
[a2, otherBatch],
|
|
)
|
|
const notOpen = await withUserContext(userId, (c) => settle(c, companyId, runId))
|
|
expect(notOpen).toMatchObject({ ok: false, code: 'CLAIM_NOT_OPEN', details: { claim_id: a2 } })
|
|
expect(await claimState([a1])).toEqual([{ id: a1, status: 'registered', payout_batch_id: null }])
|
|
expect(await ledgerCounts(companyId)).toEqual({ batches: 1, payouts: 0 })
|
|
})
|
|
|
|
it('settles nothing and answers ok for a booked run without utlägg lines', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const anna = await insertEmployee(companyId, userId)
|
|
const runId = await insertRun(companyId, userId, 'paid')
|
|
await insertSre(runId, anna, companyId)
|
|
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
|
|
|
|
const r = await withUserContext(userId, (c) => settle(c, companyId, runId))
|
|
expect(r).toMatchObject({ ok: true, claim_count: 0, already_settled: 0 })
|
|
expect(r.batches).toEqual([])
|
|
})
|
|
})
|