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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user