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.
286 lines
11 KiB
TypeScript
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 })
|
|
})
|
|
})
|