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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -0,0 +1,188 @@
|
||||
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 20260824200000_account_reconciliation_attachments:
|
||||
// RLS (members read, owner/admin/member attach as themselves, viewers
|
||||
// read-only, no DELETE policy), the append-only freeze trigger (only the
|
||||
// removal stamp may change, and only once), the no-delete trigger, and the
|
||||
// account_key / sha256 CHECKs.
|
||||
|
||||
const SHA = 'ab'.repeat(32)
|
||||
|
||||
async function insertAttachment(
|
||||
companyId: string,
|
||||
uploadedBy: string,
|
||||
overrides: { accountKey?: string; throughDate?: string; storagePath?: string } = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.account_reconciliation_attachments
|
||||
(id, company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, 'kontoutdrag.pdf', 'application/pdf', 1234, 'documents', $5, $6, $7)`,
|
||||
[
|
||||
id,
|
||||
companyId,
|
||||
overrides.accountKey ?? 'manual:2350',
|
||||
overrides.throughDate ?? '2026-12-31',
|
||||
overrides.storagePath ?? `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${id}_kontoutdrag.pdf`,
|
||||
SHA,
|
||||
uploadedBy,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('account_reconciliation_attachments RLS', () => {
|
||||
it('lets company members read, strangers see nothing', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertAttachment(companyId, userId)
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
const ownerView = await withUserContext(userId, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(ownerView.rows).toHaveLength(1)
|
||||
|
||||
const strangerView = await withUserContext(stranger, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(strangerView.rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets viewers read but not attach', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertAttachment(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.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(viewerRead.rows).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
withUserContext(viewer, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.account_reconciliation_attachments
|
||||
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
||||
VALUES ($1, 'manual:2350', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
|
||||
[companyId, `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${randomUUID()}_x.pdf`, SHA, viewer],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
it('lets members attach as themselves but not as someone else', async () => {
|
||||
const { userId: owner, companyId } = await seedCompany()
|
||||
const member = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: member, role: 'member' })
|
||||
|
||||
const inserted = await withUserContext(member, (client) =>
|
||||
client.query<{ id: string }>(
|
||||
`INSERT INTO public.account_reconciliation_attachments
|
||||
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
||||
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4) RETURNING id`,
|
||||
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, SHA, member],
|
||||
),
|
||||
)
|
||||
expect(inserted.rows).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
withUserContext(member, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.account_reconciliation_attachments
|
||||
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
||||
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
|
||||
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_y.pdf`, SHA, owner],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
it('has no DELETE policy and a no-delete trigger', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertAttachment(companyId, userId)
|
||||
|
||||
// RLS: the statement runs but touches nothing.
|
||||
const asMember = await withUserContext(userId, (client) =>
|
||||
client.query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(asMember.rowCount).toBe(0)
|
||||
|
||||
// Even the superuser cannot: BFL 7 kap. retention is enforced by trigger.
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
||||
).rejects.toThrow(/never deleted/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_reconciliation_attachments append-only', () => {
|
||||
it('lets a member stamp removal once, and freezes everything else', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertAttachment(companyId, userId)
|
||||
|
||||
await expect(
|
||||
withUserContext(userId, (client) =>
|
||||
client.query(`UPDATE public.account_reconciliation_attachments SET note = 'ändrad' WHERE id = $1`, [rowId]),
|
||||
),
|
||||
).rejects.toThrow(/append-only/i)
|
||||
|
||||
await expect(
|
||||
withUserContext(userId, (client) =>
|
||||
client.query(`UPDATE public.account_reconciliation_attachments SET storage_path = 'documents/x' WHERE id = $1`, [rowId]),
|
||||
),
|
||||
).rejects.toThrow(/append-only/i)
|
||||
|
||||
const stamped = await withUserContext(userId, (client) =>
|
||||
client.query<{ removed_at: string }>(
|
||||
`UPDATE public.account_reconciliation_attachments
|
||||
SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
|
||||
WHERE id = $1 RETURNING removed_at`,
|
||||
[rowId, userId],
|
||||
),
|
||||
)
|
||||
expect(stamped.rows).toHaveLength(1)
|
||||
|
||||
// withUserContext rolls back; stamp for real (superuser) to test finality.
|
||||
await getPool().query(
|
||||
`UPDATE public.account_reconciliation_attachments
|
||||
SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
|
||||
WHERE id = $1`,
|
||||
[rowId, userId],
|
||||
)
|
||||
|
||||
// The stamp itself is final: no restore, no re-stamp.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.account_reconciliation_attachments SET removed_at = NULL, removed_by = NULL, removed_reason = NULL WHERE id = $1`,
|
||||
[rowId],
|
||||
),
|
||||
).rejects.toThrow(/cannot be restored/i)
|
||||
})
|
||||
|
||||
it('rejects a malformed account_key, a bad hash, and a half removal stamp', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(insertAttachment(companyId, userId, { accountKey: '1930' })).rejects.toThrow(/account_key/i)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.account_reconciliation_attachments
|
||||
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
||||
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, 'nothex', $3)`,
|
||||
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, userId],
|
||||
),
|
||||
).rejects.toThrow(/sha256/i)
|
||||
const rowId = await insertAttachment(companyId, userId)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.account_reconciliation_attachments SET removed_at = NOW() WHERE id = $1`, [rowId]),
|
||||
).rejects.toThrow(/removal_pair/i)
|
||||
})
|
||||
|
||||
it('refuses the same storage object twice', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const path = `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_same.pdf`
|
||||
await insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })
|
||||
await expect(insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })).rejects.toThrow(/storage_path_unique|duplicate key/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user