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)
})
})
@@ -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 },
)
@@ -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 },
)
@@ -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<typeof import('@/lib/reconciliation/attachments')>('@/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<string, string>) => ({ 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)
})
})