Files
accounted/tests/pg/utlagg-via-lon.pg.test.ts
Jakob Wennberg 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

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([])
})
})