Files
accounted/tests/pg/expense-payout-batch-rpc.pg.test.ts
T
Joakim Hansson 397a3b9bca feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
2026-09-05 13:36:51 +02:00

286 lines
11 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 } 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
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 }> = {},
): 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', '2893', $6)`,
[id, companyId, userId, overrides.claimantName ?? 'Ägare', amountSek, overrides.status ?? 'registered'],
)
return id
}
async function callRpc(
client: PoolClient,
companyId: string,
claimIds: string[],
opts: Partial<{ date: string; cash: string }> = {},
): Promise<RpcResult> {
const { rows } = await client.query<{ r: RpcResult }>(
`SELECT public.create_expense_payout_batch($1, $2::uuid[], $3::date, $4) AS r`,
[companyId, claimIds, opts.date ?? '2026-08-31', opts.cash ?? '1930'],
)
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 })
})
})