Files
accounted/lib/reconciliation/__tests__/attachments-store.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

112 lines
4.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
getAttachmentRow,
insertAttachmentRow,
listAttachmentRows,
listAttachmentRowsInRange,
stampAttachmentRemoved,
toPublicAttachment,
} from '../attachments-store'
const COMPANY = 'company-1'
const TABLE = 'account_reconciliation_attachments'
function row(overrides: Record<string, unknown> = {}) {
return {
id: 'a1',
account_key: 'manual:2350',
through_date: '2026-12-31',
file_name: 'engagemangsbesked.pdf',
mime_type: 'application/pdf',
size_bytes: '12345',
storage_bucket: 'documents',
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
sha256: 'ab'.repeat(32),
note: null,
uploaded_by: 'u1',
uploaded_at: '2027-01-10T08:00:00Z',
removed_at: null,
removed_by: null,
removed_reason: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('attachments-store', () => {
it('lists active files for one account and balansdag, coercing size to a number', async () => {
const { supabase, enqueue, findCalls, findCall } = createQueuedMockSupabase()
enqueue({ data: [row()] })
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
expect(rows).toHaveLength(1)
expect(rows[0].size_bytes).toBe(12345)
const eqs = findCalls(TABLE, 'eq').map((a) => a[0])
expect(eqs).toEqual(['company_id', 'account_key', 'through_date'])
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
})
it('includes removed rows only on request', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: [row({ removed_at: '2027-01-11T08:00:00Z', removed_by: 'u1' })] })
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31', { includeRemoved: true })
expect(rows[0].removed_at).toBe('2027-01-11T08:00:00Z')
expect(findCall(TABLE, 'is')).toBeUndefined()
})
it('lists a date range for the pärm', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: [row(), row({ id: 'a2', account_key: 'skattekonto' })] })
const rows = await listAttachmentRowsInRange(supabase as never, COMPANY, '2026-01-01', '2026-12-31')
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2'])
expect(findCall(TABLE, 'gte')).toEqual(['through_date', '2026-01-01'])
expect(findCall(TABLE, 'lte')).toEqual(['through_date', '2026-12-31'])
})
it('strips bucket and path from the public shape', () => {
const pub = toPublicAttachment({ ...row(), size_bytes: 12345 } as never)
expect(pub).not.toHaveProperty('storage_path')
expect(pub).not.toHaveProperty('storage_bucket')
expect(pub.file_name).toBe('engagemangsbesked.pdf')
})
it('inserts with the company id and returns the row', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: row() })
const r = await insertAttachmentRow(supabase as never, COMPANY, {
account_key: 'manual:2350',
through_date: '2026-12-31',
file_name: 'engagemangsbesked.pdf',
mime_type: 'application/pdf',
size_bytes: 12345,
storage_bucket: 'documents',
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
sha256: 'ab'.repeat(32),
note: null,
uploaded_by: 'u1',
})
expect(r.id).toBe('a1')
expect(findCall(TABLE, 'insert')?.[0]).toMatchObject({ company_id: COMPANY, account_key: 'manual:2350', uploaded_by: 'u1' })
})
it('stamps removal only on an active row and returns null otherwise', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null })
expect(await stampAttachmentRemoved(supabase as never, COMPANY, 'a1', { removed_by: 'u1', reason: null })).toBeNull()
expect(findCall(TABLE, 'update')?.[0]).toMatchObject({ removed_by: 'u1', removed_reason: null })
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
})
it('reads one row by id within the account scope', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: row() })
const r = await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'a1')
expect(r?.storage_path).toContain('reconciliation')
enqueue({ data: null })
expect(await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'nope')).toBeNull()
})
})