feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)

* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 09:34:05 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ebbdf96b74
commit c62321988b
30 changed files with 2642 additions and 1 deletions
@@ -0,0 +1,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 },
)
@@ -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<typeof import('@/lib/bokslut/checklist')>('@/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)
})
})