* 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>
427 lines
18 KiB
TypeScript
427 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, insertCashAccount, insertTransaction } from './fixtures'
|
|
|
|
// pg-real coverage for 20260904171000_expense_payout_batch_rpc:
|
|
// create_expense_payout_batch books one payout verifikat, links the batch
|
|
// and marks the claims paid in one transaction, refuses non-writers, and
|
|
// serializes concurrent callers so the same claims can never be paid twice.
|
|
|
|
type RpcResult = {
|
|
ok: boolean
|
|
code?: string
|
|
transaction_id?: string | null
|
|
batch_id?: string
|
|
journal_entry_id?: string
|
|
voucher_number?: number
|
|
total_sek?: string | number
|
|
claim_count?: number
|
|
}
|
|
|
|
async function seedChart(companyId: string, userId: string): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.chart_of_accounts
|
|
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
|
|
SELECT $1, $2, n, name, cls, atype, nbal, true
|
|
FROM (VALUES
|
|
('1930', 'Företagskonto', 1, 'asset', 'debit'),
|
|
('2893', 'Skuld till aktieägare', 2, 'liability', 'credit')
|
|
) AS t(n, name, cls, atype, nbal)`,
|
|
[userId, companyId],
|
|
)
|
|
}
|
|
|
|
async function insertClaim(
|
|
companyId: string,
|
|
userId: string,
|
|
amountSek: number,
|
|
overrides: Partial<{ claimantName: string; status: string; liability: string }> = {},
|
|
): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.expense_claims
|
|
(id, company_id, user_id, claimant_name, description, expense_date, amount_sek, vat_sek, expense_account, liability_account, status)
|
|
VALUES ($1, $2, $3, $4, 'Kvitto', '2026-08-25', $5, 0, '5410', $7, $6)`,
|
|
[id, companyId, userId, overrides.claimantName ?? 'Ägare', amountSek, overrides.status ?? 'registered', overrides.liability ?? '2893'],
|
|
)
|
|
return id
|
|
}
|
|
|
|
async function callRpc(
|
|
client: PoolClient,
|
|
companyId: string,
|
|
claimIds: string[],
|
|
opts: Partial<{ date: string; cash: string; transactionId: string }> = {},
|
|
): Promise<RpcResult> {
|
|
const { rows } = await client.query<{ r: RpcResult }>(
|
|
`SELECT public.create_expense_payout_batch($1, $2::uuid[], $3::date, $4, NULL, NULL, $5::uuid) AS r`,
|
|
[companyId, claimIds, opts.date ?? '2026-08-31', opts.cash ?? '1930', opts.transactionId ?? null],
|
|
)
|
|
return rows[0].r
|
|
}
|
|
|
|
/** Like withUserContext but COMMITs, so a second session 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 payoutState(companyId: string, claimIds: string[]) {
|
|
const claims = await getPool().query<{ status: string; payout_batch_id: string | null }>(
|
|
`SELECT status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[]) ORDER BY id`,
|
|
[claimIds],
|
|
)
|
|
const batches = await getPool().query<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM public.expense_payout_batches WHERE company_id = $1`,
|
|
[companyId],
|
|
)
|
|
const entries = await getPool().query<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM public.journal_entries
|
|
WHERE company_id = $1 AND source_type = 'expense_payout' AND status = 'posted'`,
|
|
[companyId],
|
|
)
|
|
return { claims: claims.rows, batches: Number(batches.rows[0].n), postedPayouts: Number(entries.rows[0].n) }
|
|
}
|
|
|
|
describe('create_expense_payout_batch', () => {
|
|
it('books one payout verifikat, links the batch and marks the claims paid', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const a = await insertClaim(companyId, userId, 500)
|
|
const b = await insertClaim(companyId, userId, 250.5)
|
|
|
|
await withUserContext(userId, async (client) => {
|
|
const r = await callRpc(client, companyId, [a, b])
|
|
expect(r.ok).toBe(true)
|
|
expect(Number(r.total_sek)).toBe(750.5)
|
|
expect(r.claim_count).toBe(2)
|
|
expect(r.voucher_number).toBeGreaterThanOrEqual(1)
|
|
|
|
const je = await client.query<{
|
|
status: string
|
|
voucher_number: number
|
|
source_type: string
|
|
source_id: string
|
|
entry_date: string
|
|
}>(
|
|
`SELECT status, voucher_number, source_type, source_id::text, entry_date::text
|
|
FROM public.journal_entries WHERE id = $1`,
|
|
[r.journal_entry_id],
|
|
)
|
|
expect(je.rows[0]).toMatchObject({
|
|
status: 'posted',
|
|
voucher_number: r.voucher_number,
|
|
source_type: 'expense_payout',
|
|
source_id: r.batch_id,
|
|
entry_date: '2026-08-31',
|
|
})
|
|
|
|
const lines = await client.query<{ account_number: string; debit_amount: string; credit_amount: string }>(
|
|
`SELECT account_number, debit_amount::text, credit_amount::text
|
|
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY account_number`,
|
|
[r.journal_entry_id],
|
|
)
|
|
expect(
|
|
lines.rows.map((l) => ({ ...l, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount) })),
|
|
).toEqual([
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 750.5 },
|
|
{ account_number: '2893', debit_amount: 750.5, credit_amount: 0 },
|
|
])
|
|
|
|
const batch = await client.query<{ journal_entry_id: string; total_sek: string; claimant_name: string }>(
|
|
`SELECT journal_entry_id, total_sek::text, claimant_name FROM public.expense_payout_batches WHERE id = $1`,
|
|
[r.batch_id],
|
|
)
|
|
expect(batch.rows[0]).toEqual({
|
|
journal_entry_id: r.journal_entry_id,
|
|
total_sek: '750.50',
|
|
claimant_name: 'Ägare',
|
|
})
|
|
|
|
const claims = await client.query<{ status: string; payout_batch_id: string }>(
|
|
`SELECT status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[])`,
|
|
[[a, b]],
|
|
)
|
|
expect(claims.rows).toEqual([
|
|
{ status: 'paid', payout_batch_id: r.batch_id },
|
|
{ status: 'paid', payout_batch_id: r.batch_id },
|
|
])
|
|
})
|
|
})
|
|
|
|
it('refuses a retry once the claims are paid, leaving a single transfer', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const a = await insertClaim(companyId, userId, 100)
|
|
|
|
const first = await asUser(userId, (client) => callRpc(client, companyId, [a]))
|
|
expect(first.ok).toBe(true)
|
|
|
|
const second = await asUser(userId, (client) => callRpc(client, companyId, [a]))
|
|
expect(second).toMatchObject({ ok: false, code: 'ALREADY_PAID' })
|
|
|
|
expect(await payoutState(companyId, [a])).toMatchObject({ batches: 1, postedPayouts: 1 })
|
|
})
|
|
|
|
it('serializes concurrent payouts of the same claims: the loser gets ALREADY_PAID', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const a = await insertClaim(companyId, userId, 100)
|
|
const b = await insertClaim(companyId, userId, 200)
|
|
|
|
const setContext = async (client: PoolClient) => {
|
|
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 c1 = await getClient()
|
|
const c2 = await getClient()
|
|
try {
|
|
await setContext(c1)
|
|
await setContext(c2)
|
|
|
|
// Session 1 books the payout but does not commit yet: it holds the
|
|
// row locks on both claims.
|
|
const r1 = await callRpc(c1, companyId, [a, b])
|
|
expect(r1.ok).toBe(true)
|
|
|
|
// Session 2 issues the identical request. Without the FOR UPDATE it
|
|
// would read both claims as 'registered' and book a second transfer.
|
|
let settled = false
|
|
const pending = callRpc(c2, companyId, [b, a]).then((r) => {
|
|
settled = true
|
|
return r
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
expect(settled).toBe(false)
|
|
|
|
await c1.query('COMMIT')
|
|
const r2 = await pending
|
|
expect(r2).toMatchObject({ ok: false, code: 'ALREADY_PAID' })
|
|
await c2.query('ROLLBACK')
|
|
} finally {
|
|
await c1.query('ROLLBACK').catch(() => {})
|
|
await c2.query('ROLLBACK').catch(() => {})
|
|
c1.release()
|
|
c2.release()
|
|
}
|
|
|
|
const state = await payoutState(companyId, [a, b])
|
|
expect(state.batches).toBe(1)
|
|
expect(state.postedPayouts).toBe(1)
|
|
expect(state.claims.map((c) => c.status)).toEqual(['paid', 'paid'])
|
|
})
|
|
|
|
it('refuses viewers and non-members without touching the ledger', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const a = await insertClaim(companyId, userId, 100)
|
|
|
|
const viewer = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
|
const stranger = await insertAuthUser()
|
|
|
|
for (const uid of [viewer, stranger]) {
|
|
await withUserContext(uid, async (client) => {
|
|
const r = await callRpc(client, companyId, [a])
|
|
expect(r).toMatchObject({ ok: false, code: 'FORBIDDEN' })
|
|
})
|
|
}
|
|
|
|
expect(await payoutState(companyId, [a])).toMatchObject({
|
|
batches: 0,
|
|
postedPayouts: 0,
|
|
claims: [{ status: 'registered', payout_batch_id: null }],
|
|
})
|
|
})
|
|
|
|
it('refuses mixed claimants, unknown ids, and an off-chart cash account', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const owner = await insertClaim(companyId, userId, 100)
|
|
const other = await insertClaim(companyId, userId, 100, { claimantName: 'Anna Anställd' })
|
|
|
|
await withUserContext(userId, async (client) => {
|
|
expect(await callRpc(client, companyId, [owner, other])).toMatchObject({
|
|
ok: false,
|
|
code: 'MIXED_CLAIMANTS',
|
|
})
|
|
expect(await callRpc(client, companyId, [owner, randomUUID()])).toMatchObject({
|
|
ok: false,
|
|
code: 'CLAIMS_NOT_FOUND',
|
|
})
|
|
expect(await callRpc(client, companyId, [owner], { cash: '1940' })).toMatchObject({
|
|
ok: false,
|
|
code: 'ACCOUNT_NOT_IN_CHART',
|
|
})
|
|
expect(await callRpc(client, companyId, [owner], { date: '2027-03-01' })).toMatchObject({
|
|
ok: false,
|
|
code: 'FISCAL_PERIOD_NOT_FOUND',
|
|
})
|
|
})
|
|
|
|
expect(await payoutState(companyId, [owner, other])).toMatchObject({ batches: 0, postedPayouts: 0 })
|
|
})
|
|
|
|
it('books the payout FROM an unbooked bank outflow and links the row in the same transaction', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const c1 = await insertClaim(companyId, userId, 1196)
|
|
const c2 = await insertClaim(companyId, userId, 400)
|
|
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
|
const txId = await insertTransaction({
|
|
companyId,
|
|
userId,
|
|
amount: -1596,
|
|
date: '2026-08-31',
|
|
description: 'Överföring Ägare',
|
|
cashAccountId,
|
|
})
|
|
|
|
const result = await asUser(userId, (c) => callRpc(c, companyId, [c1, c2], { transactionId: txId }))
|
|
expect(result.ok).toBe(true)
|
|
expect(result.transaction_id).toBe(txId)
|
|
|
|
const { rows: tx } = await getPool().query(
|
|
`SELECT journal_entry_id, is_business, reconciliation_method FROM public.transactions WHERE id = $1`,
|
|
[txId],
|
|
)
|
|
expect(tx[0]).toEqual({
|
|
journal_entry_id: result.journal_entry_id,
|
|
is_business: true,
|
|
reconciliation_method: 'manual',
|
|
})
|
|
const { rows: claims } = await getPool().query(
|
|
`SELECT status FROM public.expense_claims WHERE id = ANY($1::uuid[])`,
|
|
[[c1, c2]],
|
|
)
|
|
expect(claims.map((r) => r.status)).toEqual(['paid', 'paid'])
|
|
const { rows: booked } = await getPool().query(`SELECT public.is_transaction_booked($1) AS b`, [txId])
|
|
expect(booked[0].b).toBe(true)
|
|
})
|
|
|
|
it('refuses a bank row whose amount differs from the claims, or that is already booked, without touching anything', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const c1 = await insertClaim(companyId, userId, 1240)
|
|
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
|
const wrongAmount = await insertTransaction({ companyId, userId, amount: -1200, cashAccountId })
|
|
|
|
const mismatch = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: wrongAmount }))
|
|
expect(mismatch).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
|
|
|
|
const inflow = await insertTransaction({ companyId, userId, amount: 1240, cashAccountId })
|
|
const notOutflow = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: inflow }))
|
|
expect(notOutflow).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
|
|
|
|
// Book the right row once, then try to book it again: the second call must
|
|
// see it as booked (the claims are already paid too, but the row check
|
|
// runs first for a fresh set of claims).
|
|
const right = await insertTransaction({ companyId, userId, amount: -1240, cashAccountId })
|
|
const first = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: right }))
|
|
expect(first.ok).toBe(true)
|
|
const c2 = await insertClaim(companyId, userId, 1240)
|
|
const again = await asUser(userId, (c) => callRpc(c, companyId, [c2], { transactionId: right }))
|
|
expect(again).toMatchObject({ ok: false, code: 'TX_ALREADY_BOOKED' })
|
|
|
|
const { rows } = await getPool().query(
|
|
`SELECT count(*)::int AS n FROM public.journal_entries WHERE company_id = $1 AND source_type = 'expense_payout'`,
|
|
[companyId],
|
|
)
|
|
expect(rows[0].n).toBe(1)
|
|
const { rows: c2rows } = await getPool().query(`SELECT status FROM public.expense_claims WHERE id = $1`, [c2])
|
|
expect(c2rows[0].status).toBe('registered')
|
|
})
|
|
|
|
it('books an enskild firma owner payout as eget uttag on 2013, never 2018', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
await getPool().query(
|
|
`INSERT INTO public.chart_of_accounts
|
|
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
|
|
VALUES ($1, $2, '2018', 'Övriga egna insättningar', 2, 'equity', 'credit', true),
|
|
($1, $2, '2013', 'Övriga egna uttag', 2, 'equity', 'debit', true)`,
|
|
[userId, companyId],
|
|
)
|
|
const c1 = await insertClaim(companyId, userId, 640, { liability: '2018' })
|
|
const result = await asUser(userId, (c) => callRpc(c, companyId, [c1]))
|
|
expect(result.ok).toBe(true)
|
|
const { rows } = await getPool().query(
|
|
`SELECT account_number, debit_amount::float AS d, credit_amount::float AS c
|
|
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
|
|
[result.journal_entry_id],
|
|
)
|
|
expect(rows).toEqual([
|
|
{ account_number: '2013', d: 640, c: 0 },
|
|
{ account_number: '1930', d: 0, c: 640 },
|
|
])
|
|
})
|
|
|
|
it('refuses a claim scheduled on a payslip line (ON_PAYSLIP, #2331) without touching the ledger', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await seedChart(companyId, userId)
|
|
const { rows: emp } = 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, 'Anna', 'Anställd', '199001015678', '5678', 'employee', '2026-01-01', 100, 'monthly')
|
|
RETURNING id`,
|
|
[companyId, userId],
|
|
)
|
|
const claim = await insertClaim(companyId, userId, 300, { claimantName: 'Anna Anställd', liability: '2820' })
|
|
await getPool().query(`UPDATE public.expense_claims SET employee_id = $2 WHERE id = $1`, [claim, emp[0].id])
|
|
const runId = 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, 6, '2026-06-25', 'draft')`,
|
|
[runId, companyId, userId],
|
|
)
|
|
const { rows: sre } = 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, emp[0].id, companyId],
|
|
)
|
|
await getPool().query(
|
|
`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, 'expense_reimbursement', 'Utlägg: Kvitto', 300, false, false, false, '2820', $3)`,
|
|
[sre[0].id, companyId, claim],
|
|
)
|
|
|
|
const r = await withUserContext(userId, (c) => callRpc(c, companyId, [claim]))
|
|
expect(r).toMatchObject({
|
|
ok: false,
|
|
code: 'ON_PAYSLIP',
|
|
details: { claim_id: claim, salary_run_id: runId, salary_run_status: 'draft', period: '2026-06' },
|
|
})
|
|
expect(await payoutState(companyId, [claim])).toMatchObject({
|
|
batches: 0,
|
|
postedPayouts: 0,
|
|
claims: [{ status: 'registered', payout_batch_id: null }],
|
|
})
|
|
})
|
|
})
|