Files
accounted/tests/pg/expense-payout-batch-rpc.pg.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

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 }],
})
})
})