* 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>
112 lines
4.5 KiB
TypeScript
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()
|
|
})
|
|
})
|