diff --git a/DECISIONS.md b/DECISIONS.md
index ef4a44a3..742ce043 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1195,3 +1195,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working.
[2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger.
[2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record.
+[2026-08-24] Reconciliation underlag (Reko bilagor, PR 2) is its own table (account_reconciliation_attachments) scoped by (company, account_key, through_date), not extra columns on document_attachments: that table's link is a verifikat and its WORM version chain is about digitized receipts, while a bilaga belongs to a balansdag and may be attached before the sign-off exists. Files stay in the `documents` bucket under `documents//reconciliation/...` so the bucket's company-scoped RLS applies unchanged; removal is a stamp (never a delete, BFL 7 kap.) enforced by trigger; the full archive copies the files into `bilagor/` with a hash manifest. No v1 API endpoints in this PR on purpose: the concurrent reconciliation-residual work edits the v1 route loader, spec snapshot and scopes, and files cannot be uploaded by an agent anyway.
+[2026-08-24] Bokslut checklist (Reko bilagor, PR 3) keeps the item catalogue in code and only the per-period state in bokslut_checklist_items: steps the system can judge (drafts, voucher gaps, trial balance, sign-offs through balansdagen, reskontra tie-outs) are computed live every time and a stored row only overrides them, so the checklist never claims a state the ledger contradicts; manual steps (inventering, osäkra fordringar, dispositioner) are what the konsult ticks. Mutable on purpose (a late verifikat reopens a step), no DELETE policy. The missing-fiscal-year check is a pure helper reused by the readiness warnings and the SIE import result; the non-adjacent previous_period_id fix is #1849 and is not duplicated here.
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslut-checklist/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-checklist/route.ts
new file mode 100644
index 00000000..dc376eaf
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-checklist/route.ts
@@ -0,0 +1,68 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { BokslutChecklistError, buildBokslutChecklist, setChecklistItem } from '@/lib/bokslut/checklist'
+
+/**
+ * GET /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
+ * PATCH /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
+ *
+ * The bokslut checklist for one räkenskapsår: the catalogue merged with the
+ * live auto states and the stored rows. PATCH ticks one item
+ * ({ item_key, state, note? }) as the acting user and returns the refreshed
+ * checklist. Catalogue and policy in lib/bokslut/checklist.ts.
+ */
+export const GET = withRouteContext(
+ 'period.bokslut_checklist',
+ async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ const checklist = await buildBokslutChecklist(supabase, companyId, user.id, id)
+ if (!checklist) return errorResponseFromCode('PERIOD_NOT_FOUND', log.child({ periodId: id }), { requestId })
+ return NextResponse.json({ data: checklist })
+ },
+)
+
+const PatchBodySchema = z.object({
+ item_key: z.string().regex(/^[a-z0-9_]{1,64}$/),
+ state: z.enum(['open', 'done', 'not_applicable']),
+ note: z.string().max(2000).nullable().optional(),
+})
+
+export const PATCH = withRouteContext(
+ 'period.bokslut_checklist.set',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
+ }
+ const parsed = PatchBodySchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json({ error: 'Ogiltig body: item_key och state (open/done/not_applicable) krävs' }, { status: 400 })
+ }
+ // The period must be this company's before anything is written.
+ const existing = await buildBokslutChecklist(supabase, companyId, user.id, id, { readiness: null })
+ if (!existing) return errorResponseFromCode('PERIOD_NOT_FOUND', log.child({ periodId: id }), { requestId })
+ try {
+ await setChecklistItem(supabase, companyId, user.id, id, {
+ item_key: parsed.data.item_key,
+ state: parsed.data.state,
+ note: parsed.data.note ?? null,
+ })
+ } catch (err) {
+ if (err instanceof BokslutChecklistError) {
+ return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: 400 })
+ }
+ throw err
+ }
+ const checklist = await buildBokslutChecklist(supabase, companyId, user.id, id)
+ return NextResponse.json({ data: checklist })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/bokslut-checklist-route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/bokslut-checklist-route.test.ts
new file mode 100644
index 00000000..2a25ff23
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/__tests__/bokslut-checklist-route.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Tests for GET/PATCH /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
+ * (cookie session, withRouteContext). The checklist service is mocked; the
+ * wrapper is real.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
+
+const { supabase, reset } = createQueuedMockSupabase()
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
+
+const buildMock = vi.fn()
+const setMock = vi.fn()
+vi.mock('@/lib/bokslut/checklist', async () => {
+ const actual = await vi.importActual('@/lib/bokslut/checklist')
+ return {
+ ...actual,
+ buildBokslutChecklist: (...args: unknown[]) => buildMock(...args),
+ setChecklistItem: (...args: unknown[]) => setMock(...args),
+ }
+})
+
+import { BokslutChecklistError } from '@/lib/bokslut/checklist'
+import { GET, PATCH } from '../[id]/bokslut-checklist/route'
+
+const p = (id: string) => ({ params: Promise.resolve({ id }) }) as never
+const CHECKLIST = {
+ period: { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
+ items: [],
+ summary: { total: 0, done: 0, not_applicable: 0, open: 0 },
+}
+
+describe('bokslut checklist route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ buildMock.mockResolvedValue(CHECKLIST)
+ setMock.mockResolvedValue({ item_key: 'inventory_valued', state: 'done' })
+ })
+
+ it('401 without a session', async () => {
+ requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
+ const res = await GET(createMockRequest('http://localhost/x'), p('fy-2026'))
+ expect(res.status).toBe(401)
+ })
+
+ it('GET returns the checklist for the period and 404s a foreign one', async () => {
+ const res = await GET(createMockRequest('http://localhost/x'), p('fy-2026'))
+ expect(res.status).toBe(200)
+ const { body } = await parseJsonResponse<{ data: { period: { id: string } } }>(res)
+ expect(body.data.period.id).toBe('fy-2026')
+ expect(buildMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'fy-2026')
+
+ buildMock.mockResolvedValue(null)
+ const missing = await GET(createMockRequest('http://localhost/x'), p('nope'))
+ expect(missing.status).toBe(404)
+ })
+
+ it('PATCH validates the body, ticks the item as the user, and returns the refreshed checklist', async () => {
+ const res = await PATCH(
+ createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'inventory_valued', state: 'done', note: 'Inventerat' } }),
+ p('fy-2026'),
+ )
+ expect(res.status).toBe(200)
+ expect(setMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'fy-2026', { item_key: 'inventory_valued', state: 'done', note: 'Inventerat' })
+ // Existence check without recomputing readiness, then the full rebuild.
+ expect(buildMock).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'user-1', 'fy-2026', { readiness: null })
+ expect(buildMock).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'user-1', 'fy-2026')
+
+ const bad = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'inventory_valued', state: 'maybe' } }), p('fy-2026'))
+ expect(bad.status).toBe(400)
+ const notJson = await PATCH(new Request('http://localhost/x', { method: 'PATCH', body: '{' }) as never, p('fy-2026'))
+ expect(notJson.status).toBe(400)
+ })
+
+ it('PATCH maps policy refusals to 400 + code, 404s a foreign period, and requires write permission', async () => {
+ setMock.mockRejectedValue(new BokslutChecklistError('Okänt steg.', 'UNKNOWN_ITEM'))
+ const refused = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'nope', state: 'done' } }), p('fy-2026'))
+ expect(refused.status).toBe(400)
+ expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('UNKNOWN_ITEM')
+
+ buildMock.mockResolvedValue(null)
+ const missing = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'no_drafts', state: 'done' } }), p('nope'))
+ expect(missing.status).toBe(404)
+
+ requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
+ const forbidden = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'no_drafts', state: 'done' } }), p('fy-2026'))
+ expect(forbidden.status).toBe(403)
+ })
+})
diff --git a/app/api/reconciliation/accounts/[accountKey]/attachments/[attachmentId]/route.ts b/app/api/reconciliation/accounts/[accountKey]/attachments/[attachmentId]/route.ts
new file mode 100644
index 00000000..85f0a73a
--- /dev/null
+++ b/app/api/reconciliation/accounts/[accountKey]/attachments/[attachmentId]/route.ts
@@ -0,0 +1,84 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { createServiceClient } from '@/lib/supabase/server'
+import { contentDisposition } from '@/lib/api/content-disposition'
+import { AccountKeySchema } from '@/lib/reconciliation/schemas'
+import { getAttachmentRow } from '@/lib/reconciliation/attachments-store'
+import { downloadUnderlag, ReconciliationAttachmentError, removeUnderlag } from '@/lib/reconciliation/attachments'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+
+const UUID = z.string().uuid()
+
+/**
+ * GET /api/reconciliation/accounts/{accountKey}/attachments/{attachmentId}
+ * streams the file inline (same shape as /api/documents/{id}/inline:
+ * the caller's client authorizes the row, the service role reads the
+ * non-public bucket).
+ * DELETE /api/reconciliation/accounts/{accountKey}/attachments/{attachmentId}
+ * stamps the attachment removed; body { reason? }. The file and the
+ * row stay (BFL 7 kap.).
+ */
+export const GET = withRouteContext<{ params: Promise<{ accountKey: string; attachmentId: string }> }>(
+ 'reconciliation.accounts.attachments.file',
+ async (_request, { supabase, companyId, log }, { params }) => {
+ const { accountKey, attachmentId } = await params
+ if (!AccountKeySchema.safeParse(accountKey).success || !UUID.safeParse(attachmentId).success) {
+ return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
+ }
+ const row = await getAttachmentRow(supabase, companyId, accountKey, attachmentId)
+ if (!row) {
+ return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
+ }
+ const { blob, error } = await downloadUnderlag(createServiceClient(), row)
+ if (error || !blob) {
+ log.error('underlag download failed', error, { companyId, accountKey, attachmentId })
+ return NextResponse.json({ error: 'Kunde inte hämta underlaget. Försök igen om en stund.' }, { status: 500 })
+ }
+ return new NextResponse(blob, {
+ status: 200,
+ headers: {
+ 'Content-Type': row.mime_type,
+ 'Content-Disposition': contentDisposition('inline', row.file_name),
+ 'Cache-Control': 'private, no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ })
+ },
+)
+
+const RemoveBodySchema = z.object({ reason: z.string().max(500).nullable().optional() })
+
+export const DELETE = withRouteContext<{ params: Promise<{ accountKey: string; attachmentId: string }> }>(
+ 'reconciliation.accounts.attachments.remove',
+ async (request, { supabase, user, companyId }, { params }) => {
+ const { accountKey, attachmentId } = await params
+ if (!AccountKeySchema.safeParse(accountKey).success || !UUID.safeParse(attachmentId).success) {
+ return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
+ }
+ let reason: string | null = null
+ const raw = await request.text()
+ if (raw.trim()) {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
+ }
+ const body = RemoveBodySchema.safeParse(parsed)
+ if (!body.success) return NextResponse.json({ error: 'Ogiltig body' }, { status: 400 })
+ reason = body.data.reason ?? null
+ }
+ try {
+ const attachment = await removeUnderlag(supabase, companyId, user.id, accountKey, attachmentId, { reason })
+ if (!attachment) return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
+ return NextResponse.json({ data: { attachment } })
+ } catch (err) {
+ if (err instanceof ReconciliationAttachmentError) {
+ return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: err.code === 'ALREADY_REMOVED' ? 409 : 400 })
+ }
+ throw err
+ }
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/reconciliation/accounts/[accountKey]/attachments/route.ts b/app/api/reconciliation/accounts/[accountKey]/attachments/route.ts
new file mode 100644
index 00000000..48839749
--- /dev/null
+++ b/app/api/reconciliation/accounts/[accountKey]/attachments/route.ts
@@ -0,0 +1,82 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { AccountKeySchema } from '@/lib/reconciliation/schemas'
+import {
+ attachUnderlag,
+ listAttachments,
+ MAX_ATTACHMENT_NOTE_LENGTH,
+ ReconciliationAttachmentError,
+} from '@/lib/reconciliation/attachments'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { ISO_DATE_RE } from '@/lib/invariants'
+
+/**
+ * GET /api/reconciliation/accounts/{accountKey}/attachments?through_date=
+ * POST /api/reconciliation/accounts/{accountKey}/attachments (multipart)
+ *
+ * The underlag of one account's balansdag: the files the account was
+ * reconciled against. GET lists them (?include_removed=1 adds the removed
+ * ones with their stamp); POST attaches one file: fields `file`,
+ * `through_date` and optional `note`. Policy in lib/reconciliation/attachments.ts.
+ */
+export const GET = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
+ 'reconciliation.accounts.attachments.list',
+ async (request, { supabase, companyId }, { params }) => {
+ const { accountKey } = await params
+ if (!AccountKeySchema.safeParse(accountKey).success) {
+ return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
+ }
+ const { searchParams } = new URL(request.url)
+ const throughDate = searchParams.get('through_date') ?? ''
+ if (!ISO_DATE_RE.test(throughDate)) {
+ return NextResponse.json({ error: 'through_date (ÅÅÅÅ-MM-DD) krävs' }, { status: 400 })
+ }
+ const attachments = await listAttachments(supabase, companyId, accountKey, throughDate, {
+ includeRemoved: searchParams.get('include_removed') === '1',
+ })
+ return NextResponse.json({ data: { attachments } })
+ },
+)
+
+export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
+ 'reconciliation.accounts.attachments.create',
+ async (request, { supabase, user, companyId }, { params }) => {
+ const { accountKey } = await params
+ if (!AccountKeySchema.safeParse(accountKey).success) {
+ return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
+ }
+ let form: FormData
+ try {
+ form = await request.formData()
+ } catch {
+ return NextResponse.json({ error: 'Ogiltig uppladdning: förväntade multipart/form-data' }, { status: 400 })
+ }
+ const file = form.get('file')
+ const throughDate = String(form.get('through_date') ?? '')
+ const noteRaw = form.get('note')
+ const note = typeof noteRaw === 'string' ? noteRaw : null
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: 'Ingen fil bifogad' }, { status: 400 })
+ }
+ if (!ISO_DATE_RE.test(throughDate)) {
+ return NextResponse.json({ error: 'through_date (ÅÅÅÅ-MM-DD) krävs' }, { status: 400 })
+ }
+ if (note && note.length > MAX_ATTACHMENT_NOTE_LENGTH) {
+ return NextResponse.json({ error: 'Noteringen är för lång' }, { status: 400 })
+ }
+ try {
+ const attachment = await attachUnderlag(supabase, companyId, user.id, accountKey, {
+ through_date: throughDate,
+ note,
+ file: { name: file.name, type: file.type, size: file.size, buffer: await file.arrayBuffer() },
+ })
+ return NextResponse.json({ data: { attachment } }, { status: 201 })
+ } catch (err) {
+ if (err instanceof ReconciliationAttachmentError) {
+ return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: 400 })
+ }
+ throw err
+ }
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/reconciliation/accounts/__tests__/attachments-route.test.ts b/app/api/reconciliation/accounts/__tests__/attachments-route.test.ts
new file mode 100644
index 00000000..7e50b822
--- /dev/null
+++ b/app/api/reconciliation/accounts/__tests__/attachments-route.test.ts
@@ -0,0 +1,188 @@
+/**
+ * Tests for the dashboard underlag routes (cookie session, withRouteContext):
+ * GET/POST /api/reconciliation/accounts/{accountKey}/attachments and
+ * GET/DELETE .../attachments/{attachmentId}. The policy layer is mocked; the wrapper is real.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
+
+const { supabase, reset } = createQueuedMockSupabase()
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
+
+const listMock = vi.fn()
+const attachMock = vi.fn()
+const removeMock = vi.fn()
+const downloadMock = vi.fn()
+vi.mock('@/lib/reconciliation/attachments', async () => {
+ const actual = await vi.importActual('@/lib/reconciliation/attachments')
+ return {
+ ...actual,
+ listAttachments: (...args: unknown[]) => listMock(...args),
+ attachUnderlag: (...args: unknown[]) => attachMock(...args),
+ removeUnderlag: (...args: unknown[]) => removeMock(...args),
+ downloadUnderlag: (...args: unknown[]) => downloadMock(...args),
+ }
+})
+const getRowMock = vi.fn()
+vi.mock('@/lib/reconciliation/attachments-store', () => ({
+ getAttachmentRow: (...args: unknown[]) => getRowMock(...args),
+}))
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: () => ({ storage: { from: () => ({}) } }),
+}))
+
+import { ReconciliationAttachmentError } from '@/lib/reconciliation/attachments'
+import { GET as listGET, POST as attachPOST } from '../[accountKey]/attachments/route'
+import { GET as fileGET, DELETE as removeDELETE } from '../[accountKey]/attachments/[attachmentId]/route'
+
+const ATTACHMENT_ID = '11111111-1111-4111-8111-111111111111'
+const p = (obj: Record) => ({ params: Promise.resolve(obj) }) as never
+const attachment = {
+ id: ATTACHMENT_ID,
+ account_key: 'manual:2350',
+ through_date: '2026-12-31',
+ file_name: 'kontoutdrag.pdf',
+ mime_type: 'application/pdf',
+ size_bytes: 10,
+ sha256: 'ab'.repeat(32),
+ note: null,
+ uploaded_by: 'user-1',
+ uploaded_at: '2027-01-10T08:00:00Z',
+ removed_at: null,
+ removed_by: null,
+ removed_reason: null,
+}
+
+describe('dashboard underlag routes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ listMock.mockResolvedValue([attachment])
+ attachMock.mockResolvedValue(attachment)
+ removeMock.mockResolvedValue({ ...attachment, removed_at: '2027-01-11T08:00:00Z', removed_by: 'user-1' })
+ })
+
+ it('401 without a session', async () => {
+ requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
+ const res = await listGET(createMockRequest('http://localhost/x?through_date=2026-12-31'), p({ accountKey: 'manual:2350' }))
+ expect(res.status).toBe(401)
+ })
+
+ it('GET lists the files for the date, 400s without a date, 404s a bad key', async () => {
+ const ok = await listGET(
+ createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/attachments?through_date=2026-12-31&include_removed=1'),
+ p({ accountKey: 'manual:2350' }),
+ )
+ expect(ok.status).toBe(200)
+ const { body } = await parseJsonResponse<{ data: { attachments: unknown[] } }>(ok)
+ expect(body.data.attachments).toHaveLength(1)
+ expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', 'manual:2350', '2026-12-31', { includeRemoved: true })
+
+ const noDate = await listGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350' }))
+ expect(noDate.status).toBe(400)
+ const badKey = await listGET(createMockRequest('http://localhost/x?through_date=2026-12-31'), p({ accountKey: '2350' }))
+ expect(badKey.status).toBe(404)
+ })
+
+ it('POST attaches the multipart file with its date and note, and maps policy refusals to 400 + code', async () => {
+ const form = new FormData()
+ form.set('file', new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], 'kontoutdrag.pdf', { type: 'application/pdf' }))
+ form.set('through_date', '2026-12-31')
+ form.set('note', 'Kontoutdrag december')
+ const req = new Request('http://localhost/api/reconciliation/accounts/manual:2350/attachments', { method: 'POST', body: form })
+ const res = await attachPOST(req as never, p({ accountKey: 'manual:2350' }))
+ expect(res.status).toBe(201)
+ expect(attachMock).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'user-1',
+ 'manual:2350',
+ expect.objectContaining({
+ through_date: '2026-12-31',
+ note: 'Kontoutdrag december',
+ file: expect.objectContaining({ name: 'kontoutdrag.pdf', type: 'application/pdf', size: 4 }),
+ }),
+ )
+
+ attachMock.mockRejectedValue(new ReconciliationAttachmentError('Filtypen stöds inte.', 'INVALID_FILE'))
+ const form2 = new FormData()
+ form2.set('file', new File(['x'], 'x.csv', { type: 'text/csv' }))
+ form2.set('through_date', '2026-12-31')
+ const refused = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: form2 }) as never, p({ accountKey: 'manual:2350' }))
+ expect(refused.status).toBe(400)
+ expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('INVALID_FILE')
+ })
+
+ it('POST 400s without a file or a date, and requires write permission', async () => {
+ const noFile = new FormData()
+ noFile.set('through_date', '2026-12-31')
+ const res = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: noFile }) as never, p({ accountKey: 'manual:2350' }))
+ expect(res.status).toBe(400)
+
+ const noDate = new FormData()
+ noDate.set('file', new File(['x'], 'x.pdf', { type: 'application/pdf' }))
+ const res2 = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: noDate }) as never, p({ accountKey: 'manual:2350' }))
+ expect(res2.status).toBe(400)
+
+ requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
+ const form = new FormData()
+ form.set('file', new File(['x'], 'x.pdf', { type: 'application/pdf' }))
+ form.set('through_date', '2026-12-31')
+ const forbidden = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: form }) as never, p({ accountKey: 'manual:2350' }))
+ expect(forbidden.status).toBe(403)
+ expect(attachMock).not.toHaveBeenCalled()
+ })
+
+ it('GET file streams the bytes inline after the row authorizes, 404s otherwise', async () => {
+ getRowMock.mockResolvedValue({ ...attachment, storage_bucket: 'documents', storage_path: 'documents/company-1/reconciliation/x' })
+ downloadMock.mockResolvedValue({ blob: new Blob(['%PDF'], { type: 'application/pdf' }), error: null })
+ const res = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
+ expect(res.status).toBe(200)
+ expect(res.headers.get('Content-Type')).toBe('application/pdf')
+ expect(res.headers.get('Content-Disposition')).toMatch(/^inline/)
+ expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff')
+
+ getRowMock.mockResolvedValue(null)
+ const missing = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
+ expect(missing.status).toBe(404)
+ const badId = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: 'nope' }))
+ expect(badId.status).toBe(404)
+ })
+
+ it('DELETE stamps removal with an optional reason, 409s an already removed file, 404s unknown', async () => {
+ const res = await removeDELETE(
+ createMockRequest('http://localhost/x', { method: 'DELETE', body: { reason: 'fel fil' } }),
+ p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }),
+ )
+ expect(res.status).toBe(200)
+ expect(removeMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'manual:2350', ATTACHMENT_ID, { reason: 'fel fil' })
+
+ const empty = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
+ expect(empty.status).toBe(200)
+ expect(removeMock).toHaveBeenLastCalledWith(supabase, 'company-1', 'user-1', 'manual:2350', ATTACHMENT_ID, { reason: null })
+
+ removeMock.mockRejectedValue(new ReconciliationAttachmentError('Underlaget är redan borttaget.', 'ALREADY_REMOVED'))
+ const gone = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
+ expect(gone.status).toBe(409)
+
+ removeMock.mockResolvedValue(null)
+ const unknown = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
+ expect(unknown.status).toBe(404)
+ })
+})
diff --git a/components/bookkeeping/year-end/BokslutChecklist.tsx b/components/bookkeeping/year-end/BokslutChecklist.tsx
new file mode 100644
index 00000000..37dd8703
--- /dev/null
+++ b/components/bookkeeping/year-end/BokslutChecklist.tsx
@@ -0,0 +1,169 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useLocale, useTranslations } from 'next-intl'
+import { ClipboardCheck } from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Skeleton } from '@/components/ui/skeleton'
+import { QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
+import { useToast } from '@/components/ui/use-toast'
+import { cn, formatDate } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import type { BokslutChecklist as Checklist, ChecklistGroup, ChecklistItem, ChecklistState } from '@/lib/bokslut/checklist'
+
+/**
+ * The bokslut checklist on the wizard's Kontroll step: every closing step
+ * grouped as the work goes, the system-judged ones read-only with their
+ * computed state, the manual ones ticked here and stored per period
+ * (bokslut_checklist_items). Any step can be marked not applicable.
+ */
+
+const GROUP_ORDER: ChecklistGroup[] = ['avstamning', 'periodisering', 'vardering', 'dispositioner', 'kontroll', 'rapportering']
+
+interface BokslutChecklistProps {
+ periodId: string
+}
+
+export function BokslutChecklist({ periodId }: BokslutChecklistProps) {
+ const t = useTranslations('bokslut_checklist')
+ const locale = useLocale()
+ const { toast } = useToast()
+ const [checklist, setChecklist] = useState(null)
+ const [failed, setFailed] = useState(false)
+ const [busy, setBusy] = useState(null)
+ const base = `/api/bookkeeping/fiscal-periods/${periodId}/bokslut-checklist`
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch(base)
+ if (!res.ok) {
+ setFailed(true)
+ return
+ }
+ const json = await res.json()
+ setChecklist(json.data as Checklist)
+ setFailed(false)
+ } catch {
+ setFailed(true)
+ }
+ }, [base])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ async function set(item: ChecklistItem, state: ChecklistState) {
+ setBusy(item.key)
+ try {
+ const res = await fetch(base, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ item_key: item.key, state }),
+ })
+ const json = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ toast({ title: t('save_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
+ return
+ }
+ setChecklist(json.data as Checklist)
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ if (failed) return null
+ if (!checklist) {
+ return (
+
+ )
+ }
+
+ const label = (item: ChecklistItem) => (locale === 'en' ? item.label_en : item.label_sv)
+
+ return (
+
+
+
+
{t('heading')}
+
+ {t('progress', { done: checklist.summary.done + checklist.summary.not_applicable, total: checklist.summary.total })}
+
+
+
+ {GROUP_ORDER.map((group) => {
+ const items = checklist.items.filter((i) => i.group === group)
+ if (items.length === 0) return null
+ return (
+
+
{t(`group_${group}`)}
+
+ {items.map((item) => {
+ const done = item.effective_state === 'done'
+ const na = item.effective_state === 'not_applicable'
+ // Auto items are judged by the system; the stored override
+ // (typically "ej tillämpligt") is the only thing to toggle.
+ const toggleable = !item.auto || item.stored_state != null
+ return (
+
+ void set(item, v === true ? 'done' : 'open')}
+ aria-label={label(item)}
+ className="mt-0.5"
+ />
+
+ {label(item)}
+ {item.auto && item.stored_state == null && (
+
+ {t('auto_chip')}
+
+ )}
+ {item.done_at && !item.auto && (
+ {t('done_at', { date: formatDate(item.done_at) })}
+ )}
+
+
+ {item.href && !done && !na && (
+
+ {t('open')}
+
+ )}
+ {na ? (
+ void set(item, 'open')} disabled={busy !== null} className={QUIET_LINK_CLASS}>
+ {t('reopen')}
+
+ ) : (
+ void set(item, 'not_applicable')}
+ disabled={busy !== null}
+ className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
+ >
+ {t('not_applicable')}
+
+ )}
+ {toggleable && item.auto && item.stored_state != null && !na && (
+ void set(item, 'open')} disabled={busy !== null} className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}>
+ {t('use_auto')}
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/components/bookkeeping/year-end/PreflightStep.tsx b/components/bookkeeping/year-end/PreflightStep.tsx
index ac8a329a..c2c812a6 100644
--- a/components/bookkeeping/year-end/PreflightStep.tsx
+++ b/components/bookkeeping/year-end/PreflightStep.tsx
@@ -7,6 +7,7 @@ import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { AlertTriangle, Info, XCircle } from 'lucide-react'
import Link from 'next/link'
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
+import { BokslutChecklist } from './BokslutChecklist'
interface PreflightStepProps {
report: BokslutReadinessReport | null
@@ -101,6 +102,8 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
)}
+
+
{report.reminders.length > 0 && (
}>
diff --git a/components/import/FiscalYearGapNotice.tsx b/components/import/FiscalYearGapNotice.tsx
new file mode 100644
index 00000000..76e59cba
--- /dev/null
+++ b/components/import/FiscalYearGapNotice.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { AlertCircle } from 'lucide-react'
+import { Card, CardContent } from '@/components/ui/card'
+import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
+import { findFiscalYearGaps, type FiscalYearGap, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
+
+/**
+ * After a SIE import: the years the company now has, with any hole between
+ * them called out. Fortnox and friends export one räkenskapsår per file, so
+ * "import the next file" is the fix and this is the moment to say it.
+ * Renders nothing while loading, on failure, or when the chain is whole.
+ */
+export function FiscalYearGapNotice() {
+ const t = useTranslations('fiscal_year_gaps')
+ const [gaps, setGaps] = useState([])
+
+ useEffect(() => {
+ let cancelled = false
+ fetch('/api/bookkeeping/fiscal-periods')
+ .then(async (res) => {
+ if (!res.ok) return
+ const json = await res.json()
+ const periods = (Array.isArray(json) ? json : (json.data ?? [])) as PeriodLike[]
+ if (!cancelled) setGaps(findFiscalYearGaps(periods))
+ })
+ .catch(() => {
+ // Advisory only.
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (gaps.length === 0) return null
+
+ return (
+
+
+
+
+
{t('title', { count: gaps.length })}
+ {gaps.map((gap) => (
+
+ {t('gap', { from: gap.missing_from, to: gap.missing_to, after: gap.after.name, before: gap.before.name })}
+
+ ))}
+
+ {t('hint')}{' '}
+
+ {t('manage')}
+
+
+
+
+
+ )
+}
diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx
index 5f5f319c..ce85953a 100644
--- a/components/import/ImportResultStep.tsx
+++ b/components/import/ImportResultStep.tsx
@@ -23,6 +23,7 @@ import { formatCurrency } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import TheaterCanvas from '@/components/import/TheaterCanvas'
+import { FiscalYearGapNotice } from '@/components/import/FiscalYearGapNotice'
import type { ImportPreview, ImportResult } from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
@@ -174,6 +175,9 @@ export default function ImportResultStep({
)}
+ {/* A year missing between the imported ones: say so here, where the next file is one click away. */}
+ {result.success && }
+
{/* IB resync notice (prior-year backfill) */}
{result.success && result.nextPeriodIBResync && (
diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx
index 22c72ab5..036af05e 100644
--- a/components/reconciliation/AccountOverview.tsx
+++ b/components/reconciliation/AccountOverview.tsx
@@ -22,6 +22,7 @@ import type {
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog'
+import { ReconciliationUnderlag } from './ReconciliationUnderlag'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
@@ -557,6 +558,12 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
)}
+ {/* Underlag for the balansdag in play: the signed date, else the date the next sign-off would cover. */}
+ = signoffDefaultDate ? status.signoff.through_date : signoffDefaultDate}
+ />
+
{/* Bridge: how the difference is explained. */}
{status.bridge.length > 0 && (
diff --git a/components/reconciliation/ReconciliationUnderlag.tsx b/components/reconciliation/ReconciliationUnderlag.tsx
new file mode 100644
index 00000000..83b681a8
--- /dev/null
+++ b/components/reconciliation/ReconciliationUnderlag.tsx
@@ -0,0 +1,177 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Paperclip } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
+import { useToast } from '@/components/ui/use-toast'
+import { cn, formatDate } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import { exceedsHostedUploadLimit, isShrinkableImage, tooLargeMessage } from '@/lib/documents/upload-size'
+import { shrinkImageForUpload } from '@/lib/documents/shrink-image'
+import type { ReconciliationAttachment } from '@/lib/reconciliation/schemas'
+
+/**
+ * The underlag of one account's balansdag: the files it was reconciled
+ * against (kontoutdrag, engagemangsbesked, reskontralista, inventering).
+ * Lists what is attached for the date, attaches more, and lets a member
+ * withdraw a wrong file (a stamp, the file stays). Reads and writes the
+ * attachments routes under /api/reconciliation/accounts/{key}/attachments.
+ */
+
+interface ReconciliationUnderlagProps {
+ accountKey: string
+ /** The balansdag the files document: the sign-off date in play. */
+ throughDate: string
+ canWrite?: boolean
+}
+
+const ACCEPT = 'application/pdf,image/jpeg,image/png,image/webp'
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function ReconciliationUnderlag({ accountKey, throughDate, canWrite = true }: ReconciliationUnderlagProps) {
+ const t = useTranslations('reconciliation_underlag')
+ const { toast } = useToast()
+ const [attachments, setAttachments] = useState(null)
+ const [busy, setBusy] = useState(null)
+ const inputRef = useRef(null)
+ const base = `/api/reconciliation/accounts/${encodeURIComponent(accountKey)}/attachments`
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch(`${base}?through_date=${throughDate}`)
+ if (!res.ok) {
+ setAttachments([])
+ return
+ }
+ const json = await res.json()
+ setAttachments((json.data?.attachments ?? []) as ReconciliationAttachment[])
+ } catch {
+ setAttachments([])
+ }
+ }, [base, throughDate])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ async function upload(files: FileList | null) {
+ if (!files || files.length === 0) return
+ setBusy('upload')
+ try {
+ for (const original of Array.from(files)) {
+ // Hosted functions refuse bodies over 4.5 MB before the route runs
+ // (no logs, no message); shrink images, refuse the rest with a reason.
+ let file = original
+ if (exceedsHostedUploadLimit(file.size) && isShrinkableImage(file.type)) {
+ file = await shrinkImageForUpload(file)
+ }
+ if (exceedsHostedUploadLimit(file.size)) {
+ toast({ title: t('too_large_title'), description: tooLargeMessage(file.size), variant: 'destructive' })
+ continue
+ }
+ const form = new FormData()
+ form.set('file', file)
+ form.set('through_date', throughDate)
+ const res = await fetch(base, { method: 'POST', body: form })
+ const json = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ toast({ title: t('upload_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
+ continue
+ }
+ toast({ title: t('attached', { name: file.name }) })
+ }
+ await load()
+ } finally {
+ setBusy(null)
+ if (inputRef.current) inputRef.current.value = ''
+ }
+ }
+
+ async function remove(attachment: ReconciliationAttachment) {
+ setBusy(attachment.id)
+ try {
+ const res = await fetch(`${base}/${attachment.id}`, { method: 'DELETE' })
+ const json = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ toast({ title: t('remove_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
+ return
+ }
+ toast({ title: t('removed', { name: attachment.file_name }) })
+ await load()
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ return (
+
+
+
+ {t('heading_dated', { date: formatDate(throughDate) })}
+
+ {canWrite && (
+ <>
+
void upload(e.target.files)}
+ aria-label={t('attach')}
+ />
+
inputRef.current?.click()}
+ disabled={busy !== null}
+ aria-busy={busy === 'upload'}
+ >
+
+ {t('attach')}
+
+ >
+ )}
+
+ {attachments === null ? null : attachments.length === 0 ? (
+ {t('empty')}
+ ) : (
+
+ {attachments.map((a) => (
+
+
+ {a.file_name}
+
+
+ {formatSize(a.size_bytes)} · {formatDate(a.uploaded_at)}
+
+ {canWrite && (
+ void remove(a)}
+ disabled={busy !== null}
+ className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS, 'shrink-0')}
+ >
+ {t('remove')}
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/lib/bokslut/__tests__/checklist.test.ts b/lib/bokslut/__tests__/checklist.test.ts
new file mode 100644
index 00000000..5e60956b
--- /dev/null
+++ b/lib/bokslut/__tests__/checklist.test.ts
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+
+const listAccountsMock = vi.fn()
+const readinessMock = vi.fn()
+vi.mock('@/lib/reconciliation/service', () => ({
+ listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
+}))
+vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
+ validateYearEndReadiness: (...args: unknown[]) => readinessMock(...args),
+}))
+
+import {
+ BOKSLUT_CHECKLIST,
+ assembleChecklist,
+ buildBokslutChecklist,
+ computeAutoStates,
+ setChecklistItem,
+ type ChecklistRow,
+} from '../checklist'
+
+const COMPANY = 'company-1'
+const USER = 'user-1'
+const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
+
+function account(overrides: Record) {
+ return {
+ account_key: 'manual:2350',
+ kind: 'manual',
+ account_number: '2350',
+ name: 'Banklån',
+ currency: 'SEK',
+ logo_url: null,
+ source: { type: 'manual', synced_at: null, stale: false },
+ status: { state: 'open', as_of: '2026-12-31T00:00:00.000Z', unexplained_difference: null, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } },
+ superseded_by: null,
+ signed_off_through: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ listAccountsMock.mockReset()
+ readinessMock.mockReset()
+})
+
+describe('catalogue', () => {
+ it('has unique keys that satisfy the table CHECK, in work order', () => {
+ const keys = BOKSLUT_CHECKLIST.map((d) => d.key)
+ expect(new Set(keys).size).toBe(keys.length)
+ for (const k of keys) expect(k).toMatch(/^[a-z0-9_]{1,64}$/)
+ expect(keys[0]).toBe('bank_signed')
+ expect(keys[keys.length - 1]).toBe('annual_accounts_reviewed')
+ })
+})
+
+describe('computeAutoStates', () => {
+ it('judges sign-offs through the balansdag, tie-outs, and readiness counts', () => {
+ const accounts = [
+ account({ account_key: 'bank:1', kind: 'bank', account_number: '1930', signed_off_through: '2026-12-31' }),
+ account({ account_key: 'bank:2', kind: 'bank', account_number: '1931', signed_off_through: '2026-11-30', superseded_by: 'bank:1' }),
+ account({ account_key: 'skattekonto', kind: 'skattekonto', account_number: '1630', signed_off_through: null }),
+ account({ account_key: 'manual:1510', account_number: '1510', status: { state: 'reconciled', as_of: 'x', unexplained_difference: 0, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } } }),
+ account({ account_key: 'manual:2350', signed_off_through: '2026-12-31' }),
+ account({ account_key: 'manual:2990', account_number: '2990', signed_off_through: '2026-06-30' }),
+ ]
+ const states = computeAutoStates(accounts as never, { draftCount: 2, unexplainedGaps: 0, trialBalanceBalanced: true }, '2026-12-31')
+ expect(Object.fromEntries(states)).toEqual({
+ bank_signed: 'done',
+ skattekonto_signed: 'open',
+ ar_reconciled: 'done',
+ ap_reconciled: 'not_applicable',
+ balance_accounts_signed: 'open',
+ no_drafts: 'open',
+ voucher_gaps_explained: 'done',
+ trial_balance_balanced: 'done',
+ })
+ })
+
+ it('marks feeds the company does not have as not applicable and leaves unknown inputs out', () => {
+ const states = computeAutoStates([], null, '2026-12-31')
+ expect(states.get('bank_signed')).toBe('not_applicable')
+ expect(states.get('skattekonto_signed')).toBe('not_applicable')
+ expect(states.has('no_drafts')).toBe(false)
+ expect(computeAutoStates(null, null, '2026-12-31').size).toBe(0)
+ })
+})
+
+describe('assembleChecklist', () => {
+ it('lets a stored row override the computed state and counts the summary', () => {
+ const rows: ChecklistRow[] = [
+ { item_key: 'skattekonto_signed', state: 'not_applicable', note: 'Inget skattekonto kopplat', done_by: USER, done_at: '2027-01-05T08:00:00Z', updated_by: USER, updated_at: '2027-01-05T08:00:00Z' },
+ { item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' },
+ ]
+ const auto = new Map([['skattekonto_signed', 'open' as const], ['no_drafts', 'done' as const]])
+ const list = assembleChecklist(PERIOD, auto, rows)
+ const byKey = Object.fromEntries(list.items.map((i) => [i.key, i]))
+ expect(byKey.skattekonto_signed).toMatchObject({ auto_state: 'open', stored_state: 'not_applicable', effective_state: 'not_applicable', note: 'Inget skattekonto kopplat' })
+ expect(byKey.inventory_valued).toMatchObject({ auto_state: null, effective_state: 'done', done_at: '2027-01-06T08:00:00Z' })
+ expect(byKey.no_drafts).toMatchObject({ auto_state: 'done', stored_state: null, effective_state: 'done' })
+ expect(byKey.accruals_posted.effective_state).toBe('open')
+ expect(list.summary).toEqual({ total: BOKSLUT_CHECKLIST.length, done: 2, not_applicable: 1, open: BOKSLUT_CHECKLIST.length - 3 })
+ })
+})
+
+describe('buildBokslutChecklist', () => {
+ it('reads the period and rows, computes readiness when not given, and survives a failed reconciliation read', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: PERIOD })
+ enqueue({ data: [{ item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' }] })
+ listAccountsMock.mockRejectedValue(new Error('down'))
+ readinessMock.mockResolvedValue({ draftCount: 0, unexplainedGaps: [{ series: 'A', from: 12, to: 12 }], trialBalanceBalanced: true })
+
+ const list = await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026')
+ expect(list?.period.id).toBe('fy-2026')
+ const byKey = Object.fromEntries(list!.items.map((i) => [i.key, i]))
+ expect(byKey.inventory_valued.effective_state).toBe('done')
+ expect(byKey.voucher_gaps_explained.effective_state).toBe('open')
+ expect(byKey.bank_signed).toMatchObject({ auto_state: null, effective_state: 'open' })
+ expect(listAccountsMock).toHaveBeenCalledWith(supabase, COMPANY, { today: '2026-12-31', windowFrom: '2026-01-01', windowTo: '2026-12-31' })
+ expect(readinessMock).toHaveBeenCalledWith(supabase, COMPANY, USER, 'fy-2026')
+ })
+
+ it('skips the readiness computation when told to, and returns null for a foreign period', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: PERIOD })
+ enqueue({ data: [] })
+ listAccountsMock.mockResolvedValue([])
+ await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026', { readiness: null })
+ expect(readinessMock).not.toHaveBeenCalled()
+
+ enqueue({ data: null })
+ expect(await buildBokslutChecklist(supabase as never, COMPANY, USER, 'nope')).toBeNull()
+ })
+})
+
+describe('setChecklistItem', () => {
+ it('upserts as the acting user with the done stamp, and refuses unknown items or bad states', async () => {
+ const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
+ enqueue({ data: { item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, done_at: 'x', updated_by: USER, updated_at: 'x' } })
+ const row = await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'done', note: ' Inventerat 2026-12-30 ' })
+ expect(row.state).toBe('done')
+ const [payload, opts] = findCall('bokslut_checklist_items', 'upsert') as [Record, { onConflict: string }]
+ expect(payload).toMatchObject({ company_id: COMPANY, fiscal_period_id: 'fy-2026', item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, updated_by: USER })
+ expect(payload.done_at).toBeTruthy()
+ expect(opts.onConflict).toBe('company_id,fiscal_period_id,item_key')
+
+ enqueue({ data: { item_key: 'inventory_valued', state: 'open', note: null, done_by: null, done_at: null, updated_by: USER, updated_at: 'x' } })
+ await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'open' })
+ const upserts = findCalls('bokslut_checklist_items', 'upsert')
+ const [reopened] = upserts[upserts.length - 1] as [Record]
+ expect(reopened).toMatchObject({ done_by: null, done_at: null })
+
+ await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'nope', state: 'done' })).rejects.toMatchObject({ code: 'UNKNOWN_ITEM' })
+ await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'no_drafts', state: 'maybe' as never })).rejects.toMatchObject({ code: 'INVALID_STATE' })
+ })
+})
diff --git a/lib/bokslut/checklist.ts b/lib/bokslut/checklist.ts
new file mode 100644
index 00000000..4ecdcf73
--- /dev/null
+++ b/lib/bokslut/checklist.ts
@@ -0,0 +1,275 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { createLogger } from '@/lib/logger'
+import { listReconciliationAccounts } from '@/lib/reconciliation/service'
+import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
+import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
+
+const log = createLogger('bokslut/checklist')
+
+/**
+ * The bokslut checklist: the closing steps a redovisningskonsult documents
+ * per räkenskapsår (Reko 760/765), as a fixed catalogue in code with one
+ * state row per item in bokslut_checklist_items. Steps the system can judge
+ * (drafts, voucher gaps, trial balance, sign-offs through balansdagen, the
+ * reskontra tie-outs) are computed live every time; a stored row overrides
+ * them (typically "ej tillämpligt") and records the manual ones. The order
+ * below is the order of the work.
+ */
+
+export type ChecklistState = 'open' | 'done' | 'not_applicable'
+export type ChecklistGroup = 'avstamning' | 'periodisering' | 'vardering' | 'dispositioner' | 'kontroll' | 'rapportering'
+
+export interface ChecklistItemDef {
+ key: string
+ group: ChecklistGroup
+ label_sv: string
+ label_en: string
+ hint_sv?: string
+ hint_en?: string
+ /** True when the system computes the state itself; a stored row still overrides. */
+ auto: boolean
+ /** Where the work is done. */
+ href?: string
+}
+
+export const BOKSLUT_CHECKLIST: readonly ChecklistItemDef[] = [
+ { key: 'bank_signed', group: 'avstamning', label_sv: 'Bankkonton avstämda och signerade per balansdagen', label_en: 'Bank accounts reconciled and signed off as of the balance sheet date', auto: true, href: '/reconciliation' },
+ { key: 'skattekonto_signed', group: 'avstamning', label_sv: 'Skattekontot avstämt mot Skatteverket och signerat', label_en: 'Tax account reconciled against Skatteverket and signed off', auto: true, href: '/reconciliation?account=skattekonto' },
+ { key: 'ar_reconciled', group: 'avstamning', label_sv: 'Kundfordringar stämda mot kundreskontran', label_en: 'Receivables agree with the customer ledger', auto: true, href: '/reconciliation?account=manual%3A1510' },
+ { key: 'ap_reconciled', group: 'avstamning', label_sv: 'Leverantörsskulder stämda mot leverantörsreskontran', label_en: 'Payables agree with the supplier ledger', auto: true, href: '/reconciliation?account=manual%3A2440' },
+ { key: 'balance_accounts_signed', group: 'avstamning', label_sv: 'Övriga balanskonton avstämda mot underlag och signerade', label_en: 'Other balance sheet accounts reconciled against documents and signed off', auto: true, href: '/reconciliation' },
+ { key: 'underlag_attached', group: 'avstamning', label_sv: 'Underlag bifogat till avstämningarna (kontoutdrag, engagemangsbesked, reskontralistor)', label_en: 'Supporting documents attached to the reconciliations (statements, engagement letters, ledger lists)', auto: false, href: '/reconciliation' },
+ { key: 'vat_settled', group: 'avstamning', label_sv: 'Momskontona avstämda mot lämnade deklarationer', label_en: 'VAT accounts agree with the filed returns', auto: false, href: '/reports/vat-declaration' },
+ { key: 'accruals_posted', group: 'periodisering', label_sv: 'Periodiseringar bokförda (förutbetalda och upplupna poster)', label_en: 'Accruals and deferrals posted', auto: false, href: '/bookkeeping/year-end/periodisering' },
+ { key: 'vacation_liability', group: 'periodisering', label_sv: 'Semesterlöneskuld och sociala avgifter på den avstämda', label_en: 'Vacation liability and its social fees reconciled', auto: false, href: '/reconciliation?account=manual%3A2920' },
+ { key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager inventerat och värderat (LVP)', label_en: 'Inventory counted and valued (lower of cost or market)', auto: false },
+ { key: 'doubtful_receivables', group: 'vardering', label_sv: 'Osäkra kundfordringar bedömda och nedskrivna vid behov', label_en: 'Doubtful receivables assessed and written down where needed', auto: false },
+ { key: 'depreciation_posted', group: 'vardering', label_sv: 'Avskrivningar bokförda enligt anläggningsregistret', label_en: 'Depreciation posted per the fixed asset register', auto: false, href: '/bookkeeping/year-end' },
+ { key: 'dispositions_posted', group: 'dispositioner', label_sv: 'Bokslutsdispositioner bokförda (periodiseringsfond, överavskrivningar)', label_en: 'Appropriations posted (tax allocation reserve, excess depreciation)', auto: false, href: '/bookkeeping/year-end' },
+ { key: 'tax_provision', group: 'dispositioner', label_sv: 'Årets skatt beräknad och bokförd', label_en: 'Current tax calculated and posted', auto: false, href: '/bookkeeping/year-end' },
+ { key: 'no_drafts', group: 'kontroll', label_sv: 'Inga utkast kvar i perioden', label_en: 'No draft entries left in the period', auto: true, href: '/bookkeeping?status=draft' },
+ { key: 'voucher_gaps_explained', group: 'kontroll', label_sv: 'Luckor i verifikationsnummerserien förklarade', label_en: 'Voucher number gaps explained', auto: true },
+ { key: 'trial_balance_balanced', group: 'kontroll', label_sv: 'Saldobalansen balanserar', label_en: 'The trial balance balances', auto: true, href: '/reports/saldobalans' },
+ { key: 'annual_accounts_reviewed', group: 'rapportering', label_sv: 'Årsbokslut eller årsredovisning upprättat och granskat', label_en: 'Annual accounts or annual report prepared and reviewed', auto: false, href: '/bookkeeping/year-end/arsredovisning' },
+]
+
+export interface ChecklistRow {
+ item_key: string
+ state: ChecklistState
+ note: string | null
+ done_by: string | null
+ done_at: string | null
+ updated_by: string
+ updated_at: string
+}
+
+export interface ChecklistItem extends ChecklistItemDef {
+ /** What the system computes, null for manual items or when it could not be computed. */
+ auto_state: ChecklistState | null
+ /** The stored row's state, null when nobody has touched the item. */
+ stored_state: ChecklistState | null
+ /** stored_state, else auto_state, else open. */
+ effective_state: ChecklistState
+ note: string | null
+ done_by: string | null
+ done_at: string | null
+}
+
+export interface BokslutChecklist {
+ period: { id: string; name: string; period_start: string; period_end: string }
+ items: ChecklistItem[]
+ summary: { total: number; done: number; not_applicable: number; open: number }
+}
+
+/** The subset of the readiness validation the auto items read. */
+export interface ChecklistReadinessInput {
+ draftCount: number
+ unexplainedGaps: number
+ trialBalanceBalanced: boolean
+}
+
+interface PeriodRow {
+ id: string
+ name: string
+ period_start: string
+ period_end: string
+}
+
+function allSigned(accounts: ReconciliationAccount[], through: string): ChecklistState {
+ if (accounts.length === 0) return 'not_applicable'
+ return accounts.every((a) => a.signed_off_through != null && a.signed_off_through >= through) ? 'done' : 'open'
+}
+
+function tieOut(accounts: ReconciliationAccount[], accountNumber: string, through: string): ChecklistState {
+ const account = accounts.find((a) => a.kind === 'manual' && a.account_number === accountNumber)
+ if (!account) return 'not_applicable'
+ if (account.signed_off_through != null && account.signed_off_through >= through) return 'done'
+ return account.status?.state === 'reconciled' ? 'done' : 'open'
+}
+
+/** Auto states from the reconciliation list and the readiness counts; pure so it is testable without a client. */
+export function computeAutoStates(
+ accounts: ReconciliationAccount[] | null,
+ readiness: ChecklistReadinessInput | null,
+ periodEnd: string,
+): Map {
+ const out = new Map()
+ if (accounts) {
+ out.set('bank_signed', allSigned(accounts.filter((a) => a.kind === 'bank' && !a.superseded_by), periodEnd))
+ out.set('skattekonto_signed', allSigned(accounts.filter((a) => a.kind === 'skattekonto'), periodEnd))
+ out.set('ar_reconciled', tieOut(accounts, '1510', periodEnd))
+ out.set('ap_reconciled', tieOut(accounts, '2440', periodEnd))
+ out.set('balance_accounts_signed', allSigned(accounts.filter((a) => a.kind === 'manual'), periodEnd))
+ }
+ if (readiness) {
+ out.set('no_drafts', readiness.draftCount === 0 ? 'done' : 'open')
+ out.set('voucher_gaps_explained', readiness.unexplainedGaps === 0 ? 'done' : 'open')
+ out.set('trial_balance_balanced', readiness.trialBalanceBalanced ? 'done' : 'open')
+ }
+ return out
+}
+
+/** Merge catalogue, auto states and stored rows into the checklist; pure. */
+export function assembleChecklist(
+ period: PeriodRow,
+ autoStates: Map,
+ rows: ChecklistRow[],
+): BokslutChecklist {
+ const byKey = new Map(rows.map((r) => [r.item_key, r]))
+ const items: ChecklistItem[] = BOKSLUT_CHECKLIST.map((def) => {
+ const row = byKey.get(def.key)
+ const auto = def.auto ? (autoStates.get(def.key) ?? null) : null
+ return {
+ ...def,
+ auto_state: auto,
+ stored_state: row?.state ?? null,
+ effective_state: row?.state ?? auto ?? 'open',
+ note: row?.note ?? null,
+ done_by: row?.done_by ?? null,
+ done_at: row?.done_at ?? null,
+ }
+ })
+ const summary = {
+ total: items.length,
+ done: items.filter((i) => i.effective_state === 'done').length,
+ not_applicable: items.filter((i) => i.effective_state === 'not_applicable').length,
+ open: items.filter((i) => i.effective_state === 'open').length,
+ }
+ return { period, items, summary }
+}
+
+export interface BuildChecklistOptions {
+ /** Pass the wizard's validation to avoid recomputing it; computed when absent. */
+ readiness?: ChecklistReadinessInput | null
+}
+
+/** The checklist for one period, or null when the period is not this company's. */
+export async function buildBokslutChecklist(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ fiscalPeriodId: string,
+ options: BuildChecklistOptions = {},
+): Promise {
+ const { data: periodData, error: periodError } = await supabase
+ .from('fiscal_periods')
+ .select('id, name, period_start, period_end')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (periodError) throw new Error(`Kunde inte hämta räkenskapsår: ${periodError.message}`)
+ const period = periodData as PeriodRow | null
+ if (!period) return null
+
+ const { data: rowData, error: rowError } = await supabase
+ .from('bokslut_checklist_items')
+ .select('item_key, state, note, done_by, done_at, updated_by, updated_at')
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ if (rowError) throw new Error(`Kunde inte hämta bokslutschecklistan: ${rowError.message}`)
+ const rows = (rowData ?? []) as ChecklistRow[]
+
+ // The live inputs are advisory: a failed read leaves the auto items without
+ // a computed state rather than hiding the checklist.
+ let accounts: ReconciliationAccount[] | null = null
+ try {
+ accounts = await listReconciliationAccounts(supabase, companyId, {
+ today: period.period_end,
+ windowFrom: period.period_start,
+ windowTo: period.period_end,
+ })
+ } catch (err) {
+ log.warn('reconciliation accounts unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
+ }
+ let readiness: ChecklistReadinessInput | null = options.readiness ?? null
+ if (readiness === undefined || readiness === null) {
+ if (options.readiness === undefined) {
+ try {
+ const v = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
+ readiness = { draftCount: v.draftCount, unexplainedGaps: v.unexplainedGaps.length, trialBalanceBalanced: v.trialBalanceBalanced }
+ } catch (err) {
+ log.warn('readiness unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
+ }
+ }
+ }
+
+ return assembleChecklist(period, computeAutoStates(accounts, readiness, period.period_end), rows)
+}
+
+export type ChecklistErrorCode = 'UNKNOWN_ITEM' | 'INVALID_STATE' | 'NOTE_TOO_LONG'
+
+export class BokslutChecklistError extends Error {
+ readonly code: ChecklistErrorCode
+ constructor(message: string, code: ChecklistErrorCode) {
+ super(message)
+ this.name = 'BokslutChecklistError'
+ this.code = code
+ }
+}
+
+export interface SetChecklistItemInput {
+ item_key: string
+ state: ChecklistState
+ note?: string | null
+}
+
+/** Upsert one item's state as the acting user; returns the stored row. */
+export async function setChecklistItem(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ fiscalPeriodId: string,
+ input: SetChecklistItemInput,
+): Promise {
+ if (!BOKSLUT_CHECKLIST.some((d) => d.key === input.item_key)) {
+ throw new BokslutChecklistError('Okänt steg i checklistan.', 'UNKNOWN_ITEM')
+ }
+ if (!['open', 'done', 'not_applicable'].includes(input.state)) {
+ throw new BokslutChecklistError('Ogiltigt läge.', 'INVALID_STATE')
+ }
+ const note = input.note?.trim() ? input.note.trim() : null
+ if (note && note.length > 2000) {
+ throw new BokslutChecklistError('Noteringen är för lång.', 'NOTE_TOO_LONG')
+ }
+ const now = new Date().toISOString()
+ const { data, error } = await supabase
+ .from('bokslut_checklist_items')
+ .upsert(
+ {
+ company_id: companyId,
+ fiscal_period_id: fiscalPeriodId,
+ item_key: input.item_key,
+ state: input.state,
+ note,
+ done_by: input.state === 'open' ? null : userId,
+ done_at: input.state === 'open' ? null : now,
+ updated_by: userId,
+ updated_at: now,
+ },
+ { onConflict: 'company_id,fiscal_period_id,item_key' },
+ )
+ .select('item_key, state, note, done_by, done_at, updated_by, updated_at')
+ .single()
+ if (error) throw new Error(`Kunde inte spara checklistan: ${error.message}`)
+ return data as ChecklistRow
+}
diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts
index f9fcc589..13d7fa92 100644
--- a/lib/bokslut/readiness-aggregator.ts
+++ b/lib/bokslut/readiness-aggregator.ts
@@ -6,6 +6,7 @@ import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
+import { describeFiscalYearGap, findFiscalYearGaps, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
import type { YearEndBlocker, YearEndValidation } from '@/types'
const log = createLogger('bokslut-readiness')
@@ -76,6 +77,26 @@ export interface BokslutReadinessReport {
* Phase 2 will replace each reminder with a concrete proposal once the
* relevant calculator ships.
*/
+/**
+ * A missing räkenskapsår anywhere in the chain is a warning on every
+ * bokslut: balances do not roll across a hole (a one-file SIE migration
+ * that skipped a year is the usual cause). Advisory: a failed read costs
+ * only this warning.
+ */
+async function fiscalYearGapWarnings(supabase: SupabaseClient, companyId: string): Promise {
+ try {
+ const { data, error } = await supabase
+ .from('fiscal_periods')
+ .select('id, name, period_start, period_end')
+ .eq('company_id', companyId)
+ if (error) throw new Error(error.message)
+ return findFiscalYearGaps((data ?? []) as PeriodLike[]).map(describeFiscalYearGap)
+ } catch (err) {
+ log.warn('fiscal year gap check failed', { companyId, error: err instanceof Error ? err.message : String(err) })
+ return []
+ }
+}
+
export async function buildBokslutReadinessReport(
supabase: SupabaseClient,
companyId: string,
@@ -249,7 +270,7 @@ export async function buildBokslutReadinessReport(
ready: validation.ready,
blockers: validation.errors,
blockerItems: validation.blockers,
- warnings: validation.warnings,
+ warnings: [...validation.warnings, ...(await fiscalYearGapWarnings(supabase, companyId))],
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,
diff --git a/lib/bookkeeping/__tests__/fiscal-year-gaps.test.ts b/lib/bookkeeping/__tests__/fiscal-year-gaps.test.ts
new file mode 100644
index 00000000..40ac2401
--- /dev/null
+++ b/lib/bookkeeping/__tests__/fiscal-year-gaps.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest'
+import { describeFiscalYearGap, findFiscalYearGaps } from '../fiscal-year-gaps'
+
+const p = (id: string, start: string, end: string) => ({ id, name: `Räkenskapsår ${start.slice(0, 4)}`, period_start: start, period_end: end })
+
+describe('findFiscalYearGaps', () => {
+ it('finds a missing calendar year between two periods regardless of input order', () => {
+ const gaps = findFiscalYearGaps([p('c', '2026-01-01', '2026-12-31'), p('a', '2024-01-01', '2024-12-31')])
+ expect(gaps).toHaveLength(1)
+ expect(gaps[0]).toMatchObject({ missing_from: '2025-01-01', missing_to: '2025-12-31' })
+ expect(gaps[0].after.id).toBe('a')
+ expect(gaps[0].before.id).toBe('c')
+ })
+
+ it('reports nothing for adjacent periods, broken years included, and for a single period', () => {
+ expect(findFiscalYearGaps([p('a', '2024-07-01', '2025-06-30'), p('b', '2025-07-01', '2026-06-30')])).toEqual([])
+ expect(findFiscalYearGaps([p('a', '2024-01-01', '2024-12-31')])).toEqual([])
+ expect(findFiscalYearGaps([])).toEqual([])
+ })
+
+ it('ignores overlaps and finds every hole in a longer chain', () => {
+ const gaps = findFiscalYearGaps([
+ p('a', '2021-01-01', '2021-12-31'),
+ p('b', '2021-06-01', '2022-05-31'),
+ p('c', '2024-01-01', '2024-12-31'),
+ p('d', '2027-01-01', '2027-12-31'),
+ ])
+ expect(gaps.map((g) => [g.missing_from, g.missing_to])).toEqual([
+ ['2022-06-01', '2023-12-31'],
+ ['2025-01-01', '2026-12-31'],
+ ])
+ })
+
+ it('describes a gap in Swedish with both neighbours named', () => {
+ const [gap] = findFiscalYearGaps([p('a', '2024-01-01', '2024-12-31'), p('c', '2026-01-01', '2026-12-31')])
+ expect(describeFiscalYearGap(gap)).toMatch(/2025-01-01 till 2025-12-31 \(mellan Räkenskapsår 2024 och Räkenskapsår 2026\)/)
+ })
+})
diff --git a/lib/bookkeeping/fiscal-year-gaps.ts b/lib/bookkeeping/fiscal-year-gaps.ts
new file mode 100644
index 00000000..47bab258
--- /dev/null
+++ b/lib/bookkeeping/fiscal-year-gaps.ts
@@ -0,0 +1,56 @@
+/**
+ * Missing räkenskapsår between the ones a company has. SIE exports carry one
+ * year per file, so a migration that imported 2024 and let the app create
+ * 2026 leaves 2025 absent: balances stop rolling, the 2026 IB is empty, and
+ * nothing said so until a bokslut failed on continuity. Pure and UTC-only,
+ * shared by the readiness warnings, the import result screen and the
+ * checklist.
+ */
+
+export interface PeriodLike {
+ id: string
+ name: string
+ period_start: string
+ period_end: string
+}
+
+export interface FiscalYearGap {
+ /** The period before the hole. */
+ after: PeriodLike
+ /** The period after the hole. */
+ before: PeriodLike
+ /** First and last missing day (inclusive). */
+ missing_from: string
+ missing_to: string
+}
+
+function shiftIsoDate(isoDate: string, days: number): string {
+ const d = new Date(isoDate + 'T00:00:00Z')
+ d.setUTCDate(d.getUTCDate() + days)
+ return d.toISOString().slice(0, 10)
+}
+
+/** Every hole between consecutive periods, oldest first. Overlaps are not gaps and are ignored. */
+export function findFiscalYearGaps(periods: readonly PeriodLike[]): FiscalYearGap[] {
+ const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
+ const gaps: FiscalYearGap[] = []
+ for (let i = 0; i + 1 < sorted.length; i += 1) {
+ const after = sorted[i]
+ const before = sorted[i + 1]
+ const expectedStart = shiftIsoDate(after.period_end, 1)
+ if (before.period_start > expectedStart) {
+ gaps.push({
+ after,
+ before,
+ missing_from: expectedStart,
+ missing_to: shiftIsoDate(before.period_start, -1),
+ })
+ }
+ }
+ return gaps
+}
+
+/** Swedish, user-facing: "Räkenskapsår saknas: 2025-01-01 till 2025-12-31 (mellan Räkenskapsår 2024 och Räkenskapsår 2026)." */
+export function describeFiscalYearGap(gap: FiscalYearGap): string {
+ return `Räkenskapsår saknas: ${gap.missing_from} till ${gap.missing_to} (mellan ${gap.after.name} och ${gap.before.name}). Importera eller skapa det innan bokslutet, annars rullar inga balanser fram.`
+}
diff --git a/lib/reconciliation/__tests__/attachments-store.test.ts b/lib/reconciliation/__tests__/attachments-store.test.ts
new file mode 100644
index 00000000..a36335f3
--- /dev/null
+++ b/lib/reconciliation/__tests__/attachments-store.test.ts
@@ -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 = {}) {
+ 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()
+ })
+})
diff --git a/lib/reconciliation/__tests__/attachments.test.ts b/lib/reconciliation/__tests__/attachments.test.ts
new file mode 100644
index 00000000..c6796231
--- /dev/null
+++ b/lib/reconciliation/__tests__/attachments.test.ts
@@ -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('../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 = {}) {
+ 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// 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) => 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
+ 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).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()
+ })
+})
diff --git a/lib/reconciliation/attachments-store.ts b/lib/reconciliation/attachments-store.ts
new file mode 100644
index 00000000..8b7764f3
--- /dev/null
+++ b/lib/reconciliation/attachments-store.ts
@@ -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): 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 {
+ 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[]).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 {
+ 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[]).map(mapRow)
+}
+
+export async function getAttachmentRow(
+ supabase: SupabaseClient,
+ companyId: string,
+ accountKey: string,
+ attachmentId: string,
+): Promise {
+ 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) : 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 {
+ 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)
+}
+
+/** 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 {
+ 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) : null
+}
diff --git a/lib/reconciliation/attachments.ts b/lib/reconciliation/attachments.ts
new file mode 100644
index 00000000..fbb200ab
Binary files /dev/null and b/lib/reconciliation/attachments.ts differ
diff --git a/lib/reconciliation/schemas.ts b/lib/reconciliation/schemas.ts
index bbacbd75..9b8828ed 100644
--- a/lib/reconciliation/schemas.ts
+++ b/lib/reconciliation/schemas.ts
@@ -80,6 +80,24 @@ export const ReconciliationSignoffSchema = z.object({
})
export type ReconciliationSignoff = z.infer
+/** 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
+
export const ReconciliationAccountSchema = z.object({
account_key: AccountKeySchema,
kind: ReconciliationKindSchema,
diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts
index 3d961327..e00df793 100644
--- a/lib/reports/__tests__/full-archive-export.test.ts
+++ b/lib/reports/__tests__/full-archive-export.test.ts
@@ -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' },
diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts
index 01691312..99924017 100644
--- a/lib/reports/full-archive-export.ts
+++ b/lib/reports/full-archive-export.ts
@@ -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///_`, 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 {
+ const manifest: ReconciliationAttachmentManifestEntry[] = []
+ const usedPaths = new Set()
+ 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 = {
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',
}
diff --git a/messages/en.json b/messages/en.json
index 50135573..1aa72028 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8056,5 +8056,40 @@
"settings_toggle_help": "Shows the driving log in the menu. There you log business trips and book tax-free mileage allowance at the statutory rate.",
"settings_save_failed_title": "Could not save the setting",
"settings_open_page": "Open the driving log"
+ },
+ "reconciliation_underlag": {
+ "heading": "Supporting documents",
+ "heading_dated": "Supporting documents as of {date}",
+ "attach": "Attach document",
+ "empty": "No supporting documents for this date. Attach the bank statement, engagement letter or other specification the account was reconciled against.",
+ "remove": "Remove",
+ "attached": "{name} attached",
+ "removed": "{name} removed. The file stays in the archive with a note about the removal.",
+ "upload_failed": "Could not attach the document",
+ "remove_failed": "Could not remove the document",
+ "too_large_title": "The file is too large"
+ },
+ "bokslut_checklist": {
+ "heading": "Closing checklist",
+ "progress": "{done} of {total} done",
+ "group_avstamning": "Reconciliations",
+ "group_periodisering": "Accruals",
+ "group_vardering": "Valuation",
+ "group_dispositioner": "Appropriations and tax",
+ "group_kontroll": "Controls",
+ "group_rapportering": "Reporting",
+ "auto_chip": "computed",
+ "done_at": "done {date}",
+ "open": "Open",
+ "reopen": "Reopen",
+ "not_applicable": "Not applicable",
+ "use_auto": "Let the system decide",
+ "save_failed": "Could not save the checklist"
+ },
+ "fiscal_year_gaps": {
+ "title": "{count, plural, one {One fiscal year is missing} other {# fiscal years are missing}}",
+ "gap": "{from} to {to} (between {after} and {before})",
+ "hint": "Import the SIE file for that year, or create the fiscal year manually, so balances roll forward.",
+ "manage": "Manage fiscal years"
}
}
diff --git a/messages/sv.json b/messages/sv.json
index 99f075e3..18ea6433 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -8056,5 +8056,40 @@
"settings_toggle_help": "Visar körjournalen i menyn. Där loggar du tjänsteresor och bokför milersättning skattefritt enligt schablon.",
"settings_save_failed_title": "Kunde inte spara inställningen",
"settings_open_page": "Öppna körjournalen"
+ },
+ "reconciliation_underlag": {
+ "heading": "Underlag",
+ "heading_dated": "Underlag per {date}",
+ "attach": "Bifoga underlag",
+ "empty": "Inget underlag bifogat för det här datumet. Bifoga kontoutdrag, engagemangsbesked eller annan specifikation som kontot stämts av mot.",
+ "remove": "Ta bort",
+ "attached": "{name} bifogad",
+ "removed": "{name} borttagen. Filen finns kvar i arkivet med en notering om borttagningen.",
+ "upload_failed": "Kunde inte bifoga underlaget",
+ "remove_failed": "Kunde inte ta bort underlaget",
+ "too_large_title": "Filen är för stor"
+ },
+ "bokslut_checklist": {
+ "heading": "Bokslutschecklista",
+ "progress": "{done} av {total} klara",
+ "group_avstamning": "Avstämningar",
+ "group_periodisering": "Periodiseringar",
+ "group_vardering": "Värdering",
+ "group_dispositioner": "Dispositioner och skatt",
+ "group_kontroll": "Kontroller",
+ "group_rapportering": "Rapportering",
+ "auto_chip": "beräknas",
+ "done_at": "klart {date}",
+ "open": "Öppna",
+ "reopen": "Öppna igen",
+ "not_applicable": "Ej tillämpligt",
+ "use_auto": "Låt systemet bedöma",
+ "save_failed": "Kunde inte spara checklistan"
+ },
+ "fiscal_year_gaps": {
+ "title": "{count, plural, one {Ett räkenskapsår saknas} other {# räkenskapsår saknas}}",
+ "gap": "{from} till {to} (mellan {after} och {before})",
+ "hint": "Importera SIE-filen för det året, eller skapa räkenskapsåret manuellt, så att balanserna rullar fram.",
+ "manage": "Hantera räkenskapsår"
}
}
diff --git a/supabase/migrations/20260824200000_account_reconciliation_attachments.sql b/supabase/migrations/20260824200000_account_reconciliation_attachments.sql
new file mode 100644
index 00000000..9215eff5
--- /dev/null
+++ b/supabase/migrations/20260824200000_account_reconciliation_attachments.sql
@@ -0,0 +1,145 @@
+-- Underlag for account reconciliation: the bank statement, engagemangsbesked,
+-- reskontralista, inventering or any other document a balance account was
+-- reconciled against, attached to (company, account_key, through_date), the
+-- same scope a sign-off (account_reconciliations) attests. A file can be
+-- attached before the sign-off exists (attach the statement, then sign) and
+-- stays with the balansdag afterwards; together they are the bokslutsbilaga
+-- Reko 140/760/765 ask a redovisningskonsult to keep per balanspost.
+--
+-- Räkenskapsinformation once it backs a bokslut (BFL 7 kap.), so rows are
+-- never deleted: a wrongly attached file gets a removal stamp (removed_at/by/
+-- reason), the storage object stays, and the pärm export lists it as removed.
+-- Every column but the removal stamp is frozen by trigger.
+
+CREATE TABLE IF NOT EXISTS public.account_reconciliation_attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ account_key TEXT NOT NULL
+ CHECK (account_key ~ '^(bank:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|skattekonto|manual:[0-9]{4})$'),
+ -- The balansdag the file documents (inclusive), matching account_reconciliations.through_date.
+ through_date DATE NOT NULL,
+ file_name TEXT NOT NULL CHECK (length(file_name) BETWEEN 1 AND 255),
+ mime_type TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
+ storage_bucket TEXT NOT NULL,
+ storage_path TEXT NOT NULL,
+ -- Content hash, so the pärm and the full archive can prove the file is the one that was attached.
+ sha256 TEXT NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
+ -- What the file is ("Kontoutdrag december", "Engagemangsbesked 2026-12-31").
+ note TEXT,
+ uploaded_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ removed_at TIMESTAMPTZ,
+ removed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ removed_reason TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT account_reconciliation_attachments_removal_pair
+ CHECK ((removed_at IS NULL) = (removed_by IS NULL)),
+ CONSTRAINT account_reconciliation_attachments_storage_path_unique UNIQUE (storage_bucket, storage_path)
+);
+
+COMMENT ON TABLE public.account_reconciliation_attachments IS
+ 'Underlag attached to a reconciliation balansdag (account_key + through_date): the bokslutsbilaga files. Append-only; removal stamps instead of deleting (BFL 7 kap.).';
+
+-- "Files for this account and balansdag" is the read on every status page and in the pärm.
+CREATE INDEX IF NOT EXISTS idx_account_reconciliation_attachments_scope
+ ON public.account_reconciliation_attachments (company_id, account_key, through_date)
+ WHERE removed_at IS NULL;
+
+-- The pärm lists every balansdag of a fiscal period in one query.
+CREATE INDEX IF NOT EXISTS idx_account_reconciliation_attachments_company_date
+ ON public.account_reconciliation_attachments (company_id, through_date);
+
+-- Only the removal stamp may change after insert; everything else is the record.
+CREATE OR REPLACE FUNCTION public.account_reconciliation_attachments_freeze()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ IF NEW.company_id IS DISTINCT FROM OLD.company_id
+ OR NEW.account_key IS DISTINCT FROM OLD.account_key
+ OR NEW.through_date IS DISTINCT FROM OLD.through_date
+ OR NEW.file_name IS DISTINCT FROM OLD.file_name
+ OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
+ OR NEW.size_bytes IS DISTINCT FROM OLD.size_bytes
+ OR NEW.storage_bucket IS DISTINCT FROM OLD.storage_bucket
+ OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
+ OR NEW.sha256 IS DISTINCT FROM OLD.sha256
+ OR NEW.note IS DISTINCT FROM OLD.note
+ OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
+ OR NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
+ RAISE EXCEPTION 'account_reconciliation_attachments rows are append-only; only the removal stamp may change'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ IF OLD.removed_at IS NOT NULL AND (
+ NEW.removed_at IS DISTINCT FROM OLD.removed_at
+ OR NEW.removed_by IS DISTINCT FROM OLD.removed_by
+ OR NEW.removed_reason IS DISTINCT FROM OLD.removed_reason) THEN
+ RAISE EXCEPTION 'a removed attachment cannot be restored or re-stamped'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_account_reconciliation_attachments_freeze ON public.account_reconciliation_attachments;
+CREATE TRIGGER trg_account_reconciliation_attachments_freeze
+ BEFORE UPDATE ON public.account_reconciliation_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.account_reconciliation_attachments_freeze();
+
+CREATE OR REPLACE FUNCTION public.account_reconciliation_attachments_no_delete()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ RAISE EXCEPTION 'account_reconciliation_attachments rows are never deleted (BFL 7 kap.); stamp removed_at instead'
+ USING ERRCODE = 'integrity_constraint_violation';
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_account_reconciliation_attachments_no_delete ON public.account_reconciliation_attachments;
+CREATE TRIGGER trg_account_reconciliation_attachments_no_delete
+ BEFORE DELETE ON public.account_reconciliation_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.account_reconciliation_attachments_no_delete();
+
+ALTER TABLE public.account_reconciliation_attachments ENABLE ROW LEVEL SECURITY;
+
+-- Every member of the company sees the underlag; owners, admins and members
+-- attach and remove (viewers look but do not touch). requireWrite on the
+-- routes is the first layer; this is defense in depth.
+DROP POLICY IF EXISTS "account_reconciliation_attachments_select" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_select" ON public.account_reconciliation_attachments
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+DROP POLICY IF EXISTS "account_reconciliation_attachments_insert" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_insert" ON public.account_reconciliation_attachments
+ FOR INSERT WITH CHECK (
+ uploaded_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "account_reconciliation_attachments_update" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_update" ON public.account_reconciliation_attachments
+ FOR UPDATE USING (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260824210000_bokslut_checklist_items.sql b/supabase/migrations/20260824210000_bokslut_checklist_items.sql
new file mode 100644
index 00000000..d8386cd6
--- /dev/null
+++ b/supabase/migrations/20260824210000_bokslut_checklist_items.sql
@@ -0,0 +1,74 @@
+-- The bokslut checklist per räkenskapsår: which closing steps are done, by
+-- whom and when, with a note. Reko 140/760 want the konsult's bokslutsarbete
+-- documented step by step; the wizard's own steps were client state that
+-- vanished on reload, so nothing recorded that the inventory was counted or
+-- the doubtful receivables reviewed.
+--
+-- The item catalogue lives in code (lib/bokslut/checklist.ts): the row is the
+-- state of one catalogue item for one period. Items the system can evaluate
+-- itself (drafts left, trial balance, sign-offs through balansdagen) are
+-- computed live; a row only overrides them (e.g. marking a step not
+-- applicable) or records the manual ones. Mutable by design: a step can be
+-- unticked when a late verifikat reopens it. The trail of who last touched a
+-- row is kept on the row; the archive dumps the table as documentation.
+
+CREATE TABLE IF NOT EXISTS public.bokslut_checklist_items (
+ company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ fiscal_period_id UUID NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
+ -- Catalogue key (lib/bokslut/checklist.ts); constrained by shape so a typo cannot create a phantom step.
+ item_key TEXT NOT NULL CHECK (item_key ~ '^[a-z0-9_]{1,64}$'),
+ state TEXT NOT NULL CHECK (state IN ('open', 'done', 'not_applicable')),
+ note TEXT CHECK (note IS NULL OR length(note) <= 2000),
+ -- Who marked it done / not applicable and when; cleared when reopened.
+ done_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ done_at TIMESTAMPTZ,
+ updated_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (company_id, fiscal_period_id, item_key),
+ CONSTRAINT bokslut_checklist_items_done_pair
+ CHECK ((state = 'open' AND done_at IS NULL) OR (state <> 'open' AND done_at IS NOT NULL))
+);
+
+COMMENT ON TABLE public.bokslut_checklist_items IS
+ 'State of one bokslut checklist item (lib/bokslut/checklist.ts) for one fiscal period: open / done / not_applicable with note and who/when.';
+
+ALTER TABLE public.bokslut_checklist_items ENABLE ROW LEVEL SECURITY;
+
+-- Members read the checklist; owners, admins and members tick it as
+-- themselves (updated_by = auth.uid()); viewers look but do not touch. No
+-- DELETE policy: a step is reopened, never erased.
+DROP POLICY IF EXISTS "bokslut_checklist_items_select" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_select" ON public.bokslut_checklist_items
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+DROP POLICY IF EXISTS "bokslut_checklist_items_insert" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_insert" ON public.bokslut_checklist_items
+ FOR INSERT WITH CHECK (
+ updated_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "bokslut_checklist_items_update" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_update" ON public.bokslut_checklist_items
+ FOR UPDATE USING (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ updated_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/pg/account-reconciliation-attachments.pg.test.ts b/tests/pg/account-reconciliation-attachments.pg.test.ts
new file mode 100644
index 00000000..d0079b1a
--- /dev/null
+++ b/tests/pg/account-reconciliation-attachments.pg.test.ts
@@ -0,0 +1,188 @@
+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 {
+ 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)
+ })
+})
diff --git a/tests/pg/bokslut-checklist-items.pg.test.ts b/tests/pg/bokslut-checklist-items.pg.test.ts
new file mode 100644
index 00000000..c040f953
--- /dev/null
+++ b/tests/pg/bokslut-checklist-items.pg.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
+
+// pg-real coverage for 20260824210000_bokslut_checklist_items: RLS (members
+// read, owner/admin/member write as themselves, viewers read-only, no
+// DELETE policy), the item_key and state CHECKs, the done pair CHECK and the
+// composite primary key (one row per item and period).
+
+async function tick(
+ companyId: string,
+ periodId: string,
+ userId: string,
+ overrides: { itemKey?: string; state?: string; doneAt?: string | null } = {},
+): Promise {
+ const state = overrides.state ?? 'done'
+ const doneAt = overrides.doneAt === undefined ? (state === 'open' ? null : new Date().toISOString()) : overrides.doneAt
+ await getPool().query(
+ `INSERT INTO public.bokslut_checklist_items
+ (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $5)`,
+ [companyId, periodId, overrides.itemKey ?? 'inventory_valued', state, userId, doneAt],
+ )
+}
+
+describe('bokslut_checklist_items RLS', () => {
+ it('lets company members read, strangers see nothing', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const stranger = await insertAuthUser()
+
+ const ownerView = await withUserContext(userId, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+
+ const strangerView = await withUserContext(stranger, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ })
+
+ it('lets viewers read but not tick', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const viewer = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
+
+ const read = await withUserContext(viewer, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(read.rows).toHaveLength(1)
+
+ await expect(
+ withUserContext(viewer, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)`,
+ [companyId, fiscalPeriodId, viewer],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('lets members tick as themselves, upsert their own rows, but not sign as someone else', async () => {
+ const { userId: owner, companyId, fiscalPeriodId } = await seedCompany()
+ const member = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: member, role: 'member' })
+
+ const inserted = await withUserContext(member, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)
+ ON CONFLICT (company_id, fiscal_period_id, item_key)
+ DO UPDATE SET state = EXCLUDED.state, done_by = EXCLUDED.done_by, done_at = EXCLUDED.done_at, updated_by = EXCLUDED.updated_by, updated_at = NOW()
+ RETURNING state`,
+ [companyId, fiscalPeriodId, member],
+ ),
+ )
+ expect(inserted.rows[0].state).toBe('done')
+
+ await expect(
+ withUserContext(member, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'tax_provision', 'done', $3, NOW(), $3)`,
+ [companyId, fiscalPeriodId, owner],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('has no DELETE policy: a member delete touches nothing', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const res = await withUserContext(userId, (client) =>
+ client.query(`DELETE FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(res.rowCount).toBe(0)
+ })
+})
+
+describe('bokslut_checklist_items constraints', () => {
+ it('rejects a malformed key, an unknown state, an open row with done_at, a done row without it, and a duplicate', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'Not Valid' })).rejects.toThrow(/item_key/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'maybe' })).rejects.toThrow(/state/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'open', doneAt: new Date().toISOString() })).rejects.toThrow(/done_pair/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'done', doneAt: null })).rejects.toThrow(/done_pair/i)
+ await tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts', state: 'not_applicable' })
+ await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts' })).rejects.toThrow(/duplicate key/i)
+ })
+})