Files
accounted/tests/pg/account-reconciliation-attachments.pg.test.ts
T
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

189 lines
8.4 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 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)
})
})