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:
Jakob Wennberg
2026-08-25 09:34:05 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ebbdf96b74
commit c62321988b
30 changed files with 2642 additions and 1 deletions
@@ -0,0 +1,111 @@
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()
})
})
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const listRowsMock = vi.fn()
const insertRowMock = vi.fn()
const getRowMock = vi.fn()
const stampMock = vi.fn()
vi.mock('../attachments-store', async () => {
const actual = await vi.importActual<typeof import('../attachments-store')>('../attachments-store')
return {
...actual,
listAttachmentRows: (...args: unknown[]) => listRowsMock(...args),
insertAttachmentRow: (...args: unknown[]) => insertRowMock(...args),
getAttachmentRow: (...args: unknown[]) => getRowMock(...args),
stampAttachmentRemoved: (...args: unknown[]) => stampMock(...args),
}
})
import {
attachUnderlag,
attachmentStoragePath,
listAttachments,
ReconciliationAttachmentError,
removeUnderlag,
} from '../attachments'
const COMPANY = 'company-1'
const USER = 'user-1'
// %PDF-1.4 header followed by padding: passes the magic-byte check.
const PDF_BYTES = new TextEncoder().encode('%PDF-1.4\n%âãÏÓ\n1 0 obj\n<<>>\nendobj\n').buffer as ArrayBuffer
function row(overrides: Record<string, unknown> = {}) {
return {
id: '11111111-1111-4111-8111-111111111111',
account_key: 'manual:2350',
through_date: '2026-12-31',
file_name: 'engagemangsbesked.pdf',
mime_type: 'application/pdf',
size_bytes: PDF_BYTES.byteLength,
storage_bucket: 'documents',
storage_path: `documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/x_engagemangsbesked.pdf`,
sha256: 'ab'.repeat(32),
note: null,
uploaded_by: USER,
uploaded_at: '2027-01-10T08:00:00Z',
removed_at: null,
removed_by: null,
removed_reason: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
listRowsMock.mockReset()
insertRowMock.mockReset()
getRowMock.mockReset()
stampMock.mockReset()
})
describe('attachmentStoragePath', () => {
it('lives under documents/<company>/ so the bucket RLS applies, with the key colon replaced', () => {
const p = attachmentStoragePath(COMPANY, 'manual:2350', '2026-12-31', 'Kontoutdrag dec/2026.pdf', 'abc')
expect(p).toBe(`documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/abc_Kontoutdrag dec_2026.pdf`)
})
})
describe('listAttachments', () => {
it('returns the public shape and rejects a bad scope', async () => {
const { supabase } = createQueuedMockSupabase()
listRowsMock.mockResolvedValue([row()])
const list = await listAttachments(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
expect(list[0]).not.toHaveProperty('storage_path')
expect(list[0].file_name).toBe('engagemangsbesked.pdf')
await expect(listAttachments(supabase as never, COMPANY, '2350', '2026-12-31')).rejects.toMatchObject({ code: 'INVALID_ACCOUNT_KEY' })
await expect(listAttachments(supabase as never, COMPANY, 'manual:2350', '31/12/2026')).rejects.toMatchObject({ code: 'INVALID_DATE' })
})
})
describe('attachUnderlag', () => {
it('validates, hashes, uploads to the documents bucket, then records the row', async () => {
const { supabase } = createQueuedMockSupabase()
insertRowMock.mockImplementation(async (_s: unknown, _c: unknown, input: Record<string, unknown>) => row(input))
const result = await attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
through_date: '2026-12-31',
note: ' Engagemangsbesked ',
file: { name: 'engagemangsbesked.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
})
const upload = supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>
expect(supabase.storage.from).toHaveBeenCalledWith('documents')
const [path, , opts] = upload.mock.calls[0] as [string, unknown, { contentType: string; upsert: boolean }]
expect(path).toMatch(new RegExp(`^documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/`))
expect(opts).toEqual({ contentType: 'application/pdf', upsert: false })
expect(insertRowMock).toHaveBeenCalledWith(
supabase,
COMPANY,
expect.objectContaining({
account_key: 'manual:2350',
through_date: '2026-12-31',
note: 'Engagemangsbesked',
uploaded_by: USER,
storage_bucket: 'documents',
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
}),
)
expect(result.file_name).toBe('engagemangsbesked.pdf')
})
it('refuses unsupported types and content that does not match the declared type without uploading', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
through_date: '2026-12-31',
file: { name: 'x.csv', type: 'text/csv', size: 10, buffer: new ArrayBuffer(10) },
}),
).rejects.toBeInstanceOf(ReconciliationAttachmentError)
await expect(
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
through_date: '2026-12-31',
file: { name: 'x.pdf', type: 'application/pdf', size: 10, buffer: new ArrayBuffer(10) },
}),
).rejects.toMatchObject({ code: 'INVALID_FILE' })
expect(supabase.storage.from('documents').upload).not.toHaveBeenCalled()
expect(insertRowMock).not.toHaveBeenCalled()
})
it('does not record a row when the upload fails', async () => {
const { supabase } = createQueuedMockSupabase()
;(supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: null,
error: { message: 'bucket down' },
})
await expect(
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
through_date: '2026-12-31',
file: { name: 'x.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
}),
).rejects.toThrow(/bucket down/)
expect(insertRowMock).not.toHaveBeenCalled()
})
})
describe('removeUnderlag', () => {
it('stamps an active row, refuses a removed one, and returns null for an unknown id', async () => {
const { supabase } = createQueuedMockSupabase()
getRowMock.mockResolvedValue(row())
stampMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER, removed_reason: 'fel fil' }))
const removed = await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id, { reason: ' fel fil ' })
expect(removed?.removed_reason).toBe('fel fil')
expect(stampMock).toHaveBeenCalledWith(supabase, COMPANY, row().id, { removed_by: USER, reason: 'fel fil' })
getRowMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER }))
await expect(removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).rejects.toMatchObject({ code: 'ALREADY_REMOVED' })
getRowMock.mockResolvedValue(null)
expect(await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).toBeNull()
})
})
+166
View File
@@ -0,0 +1,166 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ReconciliationAttachment } from './schemas'
/**
* The account_reconciliation_attachments table, read and written in one
* place. Pure storage rows: the file bytes live in Supabase Storage and are
* handled by attachments.ts, so the pärm and the archive can list underlag
* without touching the bucket.
*/
export interface AttachmentRow extends ReconciliationAttachment {
storage_bucket: string
storage_path: string
}
function mapRow(row: Record<string, unknown>): AttachmentRow {
return {
id: row.id as string,
account_key: row.account_key as string,
through_date: row.through_date as string,
file_name: row.file_name as string,
mime_type: row.mime_type as string,
size_bytes: Number(row.size_bytes ?? 0),
storage_bucket: row.storage_bucket as string,
storage_path: row.storage_path as string,
sha256: row.sha256 as string,
note: (row.note as string | null) ?? null,
uploaded_by: row.uploaded_by as string,
uploaded_at: row.uploaded_at as string,
removed_at: (row.removed_at as string | null) ?? null,
removed_by: (row.removed_by as string | null) ?? null,
removed_reason: (row.removed_reason as string | null) ?? null,
}
}
/** The public shape: no bucket or path (those are served through the file route). */
export function toPublicAttachment(row: AttachmentRow): ReconciliationAttachment {
const { storage_bucket: _bucket, storage_path: _path, ...rest } = row
void _bucket
void _path
return rest
}
export interface ListAttachmentsOptions {
includeRemoved?: boolean
}
/** Files for one account and balansdag, oldest first (the order they were attached). */
export async function listAttachmentRows(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
throughDate: string,
options: ListAttachmentsOptions = {},
): Promise<AttachmentRow[]> {
let query = supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.eq('account_key', accountKey)
.eq('through_date', throughDate)
.order('uploaded_at', { ascending: true })
if (!options.includeRemoved) query = query.is('removed_at', null)
const { data, error } = await query
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
}
/** Every active file with a balansdag inside [from, to], for the pärm and the archive. */
export async function listAttachmentRowsInRange(
supabase: SupabaseClient,
companyId: string,
from: string,
to: string,
options: ListAttachmentsOptions = {},
): Promise<AttachmentRow[]> {
let query = supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.gte('through_date', from)
.lte('through_date', to)
.order('account_key', { ascending: true })
.order('through_date', { ascending: true })
.order('uploaded_at', { ascending: true })
.limit(2000)
if (!options.includeRemoved) query = query.is('removed_at', null)
const { data, error } = await query
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
}
export async function getAttachmentRow(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
attachmentId: string,
): Promise<AttachmentRow | null> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.eq('account_key', accountKey)
.eq('id', attachmentId)
.maybeSingle()
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return data ? mapRow(data as Record<string, unknown>) : null
}
export interface InsertAttachmentInput {
account_key: string
through_date: string
file_name: string
mime_type: string
size_bytes: number
storage_bucket: string
storage_path: string
sha256: string
note: string | null
uploaded_by: string
}
export async function insertAttachmentRow(
supabase: SupabaseClient,
companyId: string,
input: InsertAttachmentInput,
): Promise<AttachmentRow> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.insert({
company_id: companyId,
account_key: input.account_key,
through_date: input.through_date,
file_name: input.file_name,
mime_type: input.mime_type,
size_bytes: input.size_bytes,
storage_bucket: input.storage_bucket,
storage_path: input.storage_path,
sha256: input.sha256,
note: input.note,
uploaded_by: input.uploaded_by,
})
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.single()
if (error) throw new Error(`Kunde inte spara underlag: ${error.message}`)
return mapRow(data as Record<string, unknown>)
}
/** Removal is a stamp; the row and the file stay (BFL 7 kap.). Null when already removed or missing. */
export async function stampAttachmentRemoved(
supabase: SupabaseClient,
companyId: string,
attachmentId: string,
input: { removed_by: string; reason: string | null },
): Promise<AttachmentRow | null> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.update({ removed_at: new Date().toISOString(), removed_by: input.removed_by, removed_reason: input.reason })
.eq('company_id', companyId)
.eq('id', attachmentId)
.is('removed_at', null)
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.maybeSingle()
if (error) throw new Error(`Kunde inte ta bort underlag: ${error.message}`)
return data ? mapRow(data as Record<string, unknown>) : null
}
Binary file not shown.
+18
View File
@@ -80,6 +80,24 @@ export const ReconciliationSignoffSchema = z.object({
})
export type ReconciliationSignoff = z.infer<typeof ReconciliationSignoffSchema>
/** One underlag file attached to an account's balansdag (account_reconciliation_attachments). */
export const ReconciliationAttachmentSchema = z.object({
id: z.string(),
account_key: AccountKeySchema,
through_date: z.string(),
file_name: z.string(),
mime_type: z.string(),
size_bytes: z.number().int(),
sha256: z.string(),
note: z.string().nullable(),
uploaded_by: z.string(),
uploaded_at: z.string(),
removed_at: z.string().nullable(),
removed_by: z.string().nullable(),
removed_reason: z.string().nullable(),
})
export type ReconciliationAttachment = z.infer<typeof ReconciliationAttachmentSchema>
export const ReconciliationAccountSchema = z.object({
account_key: AccountKeySchema,
kind: ReconciliationKindSchema,