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
@@ -51,6 +51,11 @@ vi.mock('../journal-register', () => ({
}),
}))
// The bilagor step reads account_reconciliation_attachments through the
// store; an empty list keeps the queued-mock order of these tests intact.
vi.mock('@/lib/reconciliation/attachments-store', () => ({
listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
+94
View File
@@ -9,6 +9,7 @@ import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import {
@@ -171,6 +172,7 @@ export async function generateFullArchive(
if (options.include_documents !== false) {
await writeDocuments(zip, supabase, companyId, periods, options.scope)
await writeReconciliationAttachments(zip, supabase, companyId, periods)
}
if (options.scope === 'all') {
@@ -554,6 +556,94 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
interface ReconciliationAttachmentManifestEntry {
attachment_id: string
account_key: string
through_date: string
file_name: string
storage_path: string
sha256: string
mime_type: string
size_bytes: number
note: string | null
uploaded_at: string
removed_at: string | null
removed_reason: string | null
zip_path: string | null
status: 'downloaded' | 'removed' | 'error'
error?: string
}
/**
* The underlag behind the reconciliation sign-offs (bokslutsbilagor): every
* file attached to a balansdag inside the archived periods, laid out as
* `bilagor/<period>/<account_key>/<through_date>_<file>`, plus a manifest
* with the content hashes. Removed files are listed (with their stamp) but
* not copied: the manifest is the record that they were attached and then
* withdrawn. A failed read lands in the manifest rather than aborting the
* archive, like writeDocuments.
*/
async function writeReconciliationAttachments(
zip: JSZip,
supabase: SupabaseClient,
companyId: string,
periods: FiscalPeriodRow[]
): Promise<void> {
const manifest: ReconciliationAttachmentManifestEntry[] = []
const usedPaths = new Set<string>()
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const from = sorted[0].period_start
const to = sorted[sorted.length - 1].period_end
try {
const rows = await listAttachmentRowsInRange(supabase, companyId, from, to, { includeRemoved: true })
for (const row of rows) {
const period = sorted.find((p) => row.through_date >= p.period_start && row.through_date <= p.period_end)
const base = {
attachment_id: row.id,
account_key: row.account_key,
through_date: row.through_date,
file_name: row.file_name,
storage_path: row.storage_path,
sha256: row.sha256,
mime_type: row.mime_type,
size_bytes: row.size_bytes,
note: row.note,
uploaded_at: row.uploaded_at,
removed_at: row.removed_at,
removed_reason: row.removed_reason,
}
if (row.removed_at) {
manifest.push({ ...base, zip_path: null, status: 'removed' })
continue
}
if (!period) continue
let zipPath = `bilagor/${periodLabel(period)}/${row.account_key.replace(':', '_')}/${row.through_date}_${row.file_name}`
if (usedPaths.has(zipPath)) {
const dot = zipPath.lastIndexOf('.')
const suffix = `_${row.id.slice(0, 8)}`
zipPath = dot > zipPath.lastIndexOf('/') ? `${zipPath.slice(0, dot)}${suffix}${zipPath.slice(dot)}` : `${zipPath}${suffix}`
}
usedPaths.add(zipPath)
try {
const { data, error } = await supabase.storage.from(row.storage_bucket).download(row.storage_path)
if (error || !data) {
manifest.push({ ...base, zip_path: null, status: 'error', error: error?.message || 'Download returned no data' })
continue
}
zip.file(zipPath, await data.arrayBuffer())
manifest.push({ ...base, zip_path: zipPath, status: 'downloaded' })
} catch (err) {
manifest.push({ ...base, zip_path: null, status: 'error', error: err instanceof Error ? err.message : 'Unknown error' })
}
}
} catch {
// Attachment listing failed: the archive still carries everything else.
}
zip.folder('bilagor')!.file('manifest.json', JSON.stringify(manifest, null, 2))
}
/**
* PostgREST returns a many-to-one embedded resource as either an object or an
* array depending on schema introspection (FK is unique vs not). Normalize.
@@ -988,6 +1078,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
// through which date with the numbers as they stood, plus reopen stamps.
// Part of the avstämningsdokumentation an auditor asks for; kept.
{ name: 'account_reconciliations', file: 'account_reconciliations.json', orderBy: 'signed_at' },
// The bokslut checklist per räkenskapsår (which closing steps were done,
// by whom, when): the konsult's documented bokslutsarbete (Reko 760); kept.
{ name: 'bokslut_checklist_items', file: 'bokslut_checklist_items.json', orderBy: 'updated_at', pageKey: 'item_key' },
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
// No `denormalize`: rot_rut_payout_requests has no currency column either.
@@ -1017,6 +1110,7 @@ export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record<string, string> = {
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
audit_log: 'revision/behandlingshistorik.json',
document_attachments: 'dokument/ + dokument/manifest.json',
account_reconciliation_attachments: 'bilagor/ + bilagor/manifest.json',
sie_imports: 'sie/imports.json + sie/original/',
sie_account_mappings: 'sie/account_mappings.json',
}