Files
accounted/tests/pg/bokslut-checklist-items.pg.test.ts
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:34:05 +02:00

114 lines
5.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
// pg-real coverage for 20260824210000_bokslut_checklist_items: RLS (members
// read, owner/admin/member write as themselves, viewers read-only, no
// DELETE policy), the item_key and state CHECKs, the done pair CHECK and the
// composite primary key (one row per item and period).
async function tick(
companyId: string,
periodId: string,
userId: string,
overrides: { itemKey?: string; state?: string; doneAt?: string | null } = {},
): Promise<void> {
const state = overrides.state ?? 'done'
const doneAt = overrides.doneAt === undefined ? (state === 'open' ? null : new Date().toISOString()) : overrides.doneAt
await getPool().query(
`INSERT INTO public.bokslut_checklist_items
(company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $5)`,
[companyId, periodId, overrides.itemKey ?? 'inventory_valued', state, userId, doneAt],
)
}
describe('bokslut_checklist_items RLS', () => {
it('lets company members read, strangers see nothing', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await tick(companyId, fiscalPeriodId, userId)
const stranger = await insertAuthUser()
const ownerView = await withUserContext(userId, (client) =>
client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
)
expect(ownerView.rows).toHaveLength(1)
const strangerView = await withUserContext(stranger, (client) =>
client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
)
expect(strangerView.rows).toHaveLength(0)
})
it('lets viewers read but not tick', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await tick(companyId, fiscalPeriodId, userId)
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
const read = await withUserContext(viewer, (client) =>
client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
)
expect(read.rows).toHaveLength(1)
await expect(
withUserContext(viewer, (client) =>
client.query(
`INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)`,
[companyId, fiscalPeriodId, viewer],
),
),
).rejects.toThrow(/row-level security/i)
})
it('lets members tick as themselves, upsert their own rows, but not sign as someone else', async () => {
const { userId: owner, companyId, fiscalPeriodId } = await seedCompany()
const member = await insertAuthUser()
await insertCompanyMember({ companyId, userId: member, role: 'member' })
const inserted = await withUserContext(member, (client) =>
client.query(
`INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)
ON CONFLICT (company_id, fiscal_period_id, item_key)
DO UPDATE SET state = EXCLUDED.state, done_by = EXCLUDED.done_by, done_at = EXCLUDED.done_at, updated_by = EXCLUDED.updated_by, updated_at = NOW()
RETURNING state`,
[companyId, fiscalPeriodId, member],
),
)
expect(inserted.rows[0].state).toBe('done')
await expect(
withUserContext(member, (client) =>
client.query(
`INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
VALUES ($1, $2, 'tax_provision', 'done', $3, NOW(), $3)`,
[companyId, fiscalPeriodId, owner],
),
),
).rejects.toThrow(/row-level security/i)
})
it('has no DELETE policy: a member delete touches nothing', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await tick(companyId, fiscalPeriodId, userId)
const res = await withUserContext(userId, (client) =>
client.query(`DELETE FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
)
expect(res.rowCount).toBe(0)
})
})
describe('bokslut_checklist_items constraints', () => {
it('rejects a malformed key, an unknown state, an open row with done_at, a done row without it, and a duplicate', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'Not Valid' })).rejects.toThrow(/item_key/i)
await expect(tick(companyId, fiscalPeriodId, userId, { state: 'maybe' })).rejects.toThrow(/state/i)
await expect(tick(companyId, fiscalPeriodId, userId, { state: 'open', doneAt: new Date().toISOString() })).rejects.toThrow(/done_pair/i)
await expect(tick(companyId, fiscalPeriodId, userId, { state: 'done', doneAt: null })).rejects.toThrow(/done_pair/i)
await tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts', state: 'not_applicable' })
await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts' })).rejects.toThrow(/duplicate key/i)
})
})