Files
accounted/tests/pg/expense-claims.pg.test.ts
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

204 lines
8.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
// pg-real coverage for 20260904170000_expense_claims: RLS (member SELECT,
// owner/admin/member writes, viewers read-only, strangers see nothing) and
// the CHECK constraints (vat_sek < amount_sek, paid requires a payout batch,
// cash/liability account whitelists on payout batches).
async function insertClaim(
companyId: string,
userId: string,
overrides: Partial<{ amountSek: number; vatSek: number }> = {},
): 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)
VALUES ($1, $2, $3, 'Ägare', 'USB-hubb', '2026-08-25', $4, $5, '5410')`,
[id, companyId, userId, overrides.amountSek ?? 500, overrides.vatSek ?? 100],
)
return id
}
describe('expense_claims RLS', () => {
it('lets company members read, strangers see nothing', async () => {
const { userId, companyId } = await seedCompany()
const claimId = await insertClaim(companyId, userId)
const stranger = await insertAuthUser()
const memberView = await withUserContext(userId, (client) =>
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
)
expect(memberView.rows).toHaveLength(1)
const strangerView = await withUserContext(stranger, (client) =>
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
)
expect(strangerView.rows).toHaveLength(0)
})
it('lets viewers read but not register claims', async () => {
const { userId, companyId } = await seedCompany()
const claimId = await insertClaim(companyId, userId)
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
const viewerRead = await withUserContext(viewer, (client) =>
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
)
expect(viewerRead.rows).toHaveLength(1)
await expect(
withUserContext(viewer, (client) =>
client.query(
`INSERT INTO public.expense_claims
(company_id, user_id, claimant_name, description, expense_date, amount_sek, expense_account)
VALUES ($1, $2, 'Ägare', 'USB-hubb', '2026-08-25', 500, '5410')`,
[companyId, viewer],
),
),
).rejects.toThrow(/row-level security/)
})
it('viewers cannot update or delete claims', async () => {
const { userId, companyId } = await seedCompany()
const claimId = await insertClaim(companyId, userId)
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
// RLS filters the rows out of UPDATE/DELETE scope: 0 rows affected.
const upd = await withUserContext(viewer, (client) =>
client.query(`UPDATE public.expense_claims SET description = 'x' WHERE id = $1`, [claimId]),
)
expect(upd.rowCount).toBe(0)
const der = await withUserContext(viewer, (client) =>
client.query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
)
expect(der.rowCount).toBe(0)
const still = await getPool().query(`SELECT description FROM public.expense_claims WHERE id = $1`, [claimId])
expect(still.rows[0].description).toBe('USB-hubb')
})
it('members cannot write into another company', async () => {
const { userId } = await seedCompany()
const other = await seedCompany()
await expect(
withUserContext(userId, (client) =>
client.query(
`INSERT INTO public.expense_claims
(company_id, user_id, claimant_name, description, expense_date, amount_sek, expense_account)
VALUES ($1, $2, 'Ägare', 'USB-hubb', '2026-08-25', 500, '5410')`,
[other.companyId, userId],
),
),
).rejects.toThrow(/row-level security/)
})
})
describe('expense_claims constraints', () => {
it('rejects vat_sek >= amount_sek', async () => {
const { userId, companyId } = await seedCompany()
await expect(insertClaim(companyId, userId, { amountSek: 100, vatSek: 100 })).rejects.toThrow(
/check constraint/i,
)
})
it('rejects status paid without a payout batch', async () => {
const { userId, companyId } = await seedCompany()
const claimId = await insertClaim(companyId, userId)
await expect(
getPool().query(`UPDATE public.expense_claims SET status = 'paid' WHERE id = $1`, [claimId]),
).rejects.toThrow(/check/i)
})
it('refuses an employee_id from another company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const foreignEmployee = randomUUID()
await getPool().query(
`INSERT INTO public.employees (id, company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_start)
VALUES ($1, $2, $3, 'Test', 'Testsson', 'enc', '0000', '2026-01-01')`,
[foreignEmployee, b.companyId, b.userId],
)
// A claim in company A pointing at company B's employee: the composite FK
// must refuse it even though company_id is A's own.
await expect(
getPool().query(
`INSERT INTO public.expense_claims
(company_id, user_id, employee_id, claimant_name, description, expense_date, amount_sek, expense_account)
VALUES ($1, $2, $3, 'Test Testsson', 'USB-hubb', '2026-08-25', 500, '5410')`,
[a.companyId, a.userId, foreignEmployee],
),
).rejects.toThrow(/foreign key/)
// Same claim shape but referencing an employee in A's own company is fine.
const ownEmployee = randomUUID()
await getPool().query(
`INSERT INTO public.employees (id, company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_start)
VALUES ($1, $2, $3, 'Egen', 'Anställd', 'enc', '0001', '2026-01-01')`,
[ownEmployee, a.companyId, a.userId],
)
const ok = await getPool().query(
`INSERT INTO public.expense_claims
(company_id, user_id, employee_id, claimant_name, description, expense_date, amount_sek, expense_account)
VALUES ($1, $2, $3, 'Egen Anställd', 'USB-hubb', '2026-08-25', 500, '5410')`,
[a.companyId, a.userId, ownEmployee],
)
expect(ok.rowCount).toBe(1)
})
it('refuses a payout batch from another company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const claimId = await insertClaim(a.companyId, a.userId)
// A batch that exists, but in the other company.
const foreign = await getPool().query<{ id: string }>(
`INSERT INTO public.expense_payout_batches
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
VALUES ($1, $2, 'Ägare', '2026-08-31', '1930', '2893', 500) RETURNING id`,
[b.companyId, b.userId],
)
await expect(
getPool().query(
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $1 WHERE id = $2`,
[foreign.rows[0].id, claimId],
),
).rejects.toThrow(/foreign key/)
// The same shape inside the claim's own company is accepted.
const own = await getPool().query<{ id: string }>(
`INSERT INTO public.expense_payout_batches
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
VALUES ($1, $2, 'Ägare', '2026-08-31', '1930', '2893', 500) RETURNING id`,
[a.companyId, a.userId],
)
const ok = await getPool().query(
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $1 WHERE id = $2`,
[own.rows[0].id, claimId],
)
expect(ok.rowCount).toBe(1)
})
it('rejects a non-19xx cash account and an off-list liability account on batches', async () => {
const { userId, companyId } = await seedCompany()
const insertBatch = (cashAccount: string, liabilityAccount: string) =>
getPool().query(
`INSERT INTO public.expense_payout_batches
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
VALUES ($1, $2, 'Ägare', '2026-08-31', $3, $4, 500)`,
[companyId, userId, cashAccount, liabilityAccount],
)
await expect(insertBatch('2440', '2893')).rejects.toThrow(/cash_account/)
await expect(insertBatch('1930', '2440')).rejects.toThrow(/liability_account/)
await expect(insertBatch('1930', '2893')).resolves.toBeTruthy()
})
})