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 ( +
+
+
+ ) +} 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 && (