feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)

* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

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

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

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

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

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>
This commit is contained in:
Jakob Wennberg
2026-08-25 09:35:02 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c62321988b
commit 9ce1ebc65f
17 changed files with 1257 additions and 1 deletions
@@ -0,0 +1,99 @@
/**
* Tests for GET /api/reports/bokslutsbilagor (cookie session, withRouteContext).
* The generator and the PDF renderer are mocked; the wrapper and the query
* validation are 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'),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({ from: vi.fn() }),
}))
const generateMock = vi.fn()
vi.mock('@/lib/reports/bokslutsbilagor', () => ({
generateBokslutsbilagor: (...args: unknown[]) => generateMock(...args),
}))
const renderMock = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderMock(...args),
}))
vi.mock('@/lib/reports/bokslutsbilagor-pdf-template', () => ({
BokslutsbilagorPDF: () => null,
}))
vi.mock('@/lib/reports/behandlingshistorik', () => ({
resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()),
}))
import { GET } from '../route'
const PERIOD_ID = '11111111-1111-4111-8111-111111111111'
const REPORT = {
company: { name: 'Väla Redovisning AB', org_number: '5592383508' },
period: { id: PERIOD_ID, name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' },
generated_at: '2027-01-15T10:00:00Z',
app_version: null,
checklist: { items: [], summary: { total: 0, done: 0, not_applicable: 0, open: 0 } },
accounts: [],
summary: { accounts: 0, signed_on_balansdag: 0, signed_other_date: 0, unsigned: 0, attachments: 0 },
}
describe('GET /api/reports/bokslutsbilagor', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
generateMock.mockResolvedValue(REPORT)
renderMock.mockResolvedValue(Buffer.from('%PDF-1.4 stub'))
})
it('401 without a session', async () => {
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`))
expect(res.status).toBe(401)
})
it('400s a missing or malformed period_id and an unknown format', async () => {
expect((await GET(createMockRequest('http://localhost/api/reports/bokslutsbilagor'))).status).toBe(400)
expect((await GET(createMockRequest('http://localhost/api/reports/bokslutsbilagor?period_id=nope'))).status).toBe(400)
expect((await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}&format=xlsx`))).status).toBe(400)
expect(generateMock).not.toHaveBeenCalled()
})
it('returns the JSON report for the user, private and uncached', async () => {
const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`))
expect(res.status).toBe(200)
expect(res.headers.get('Cache-Control')).toMatch(/no-store/)
const { body } = await parseJsonResponse<{ data: { period: { id: string } } }>(res)
expect(body.data.period.id).toBe(PERIOD_ID)
expect(generateMock).toHaveBeenCalledWith(supabase, 'company-1', PERIOD_ID, expect.objectContaining({ userId: 'user-1' }))
})
it('renders the PDF with a dated filename', async () => {
const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}&format=pdf`))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toMatch(/bokslutsbilagor-.*-20261231\.pdf/)
expect(renderMock).toHaveBeenCalledTimes(1)
})
it('404s an unknown period and 500s a generator failure without leaking the message', async () => {
generateMock.mockResolvedValue(null)
expect((await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`))).status).toBe(404)
generateMock.mockRejectedValue(new Error('relation account_reconciliations does not exist'))
const failed = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`))
expect(failed.status).toBe(500)
expect(JSON.stringify(await parseJsonResponse(failed))).not.toMatch(/relation/)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { contentDisposition } from '@/lib/api/content-disposition'
import { privateNoStore } from '@/lib/api/private-no-store'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { generateBokslutsbilagor } from '@/lib/reports/bokslutsbilagor'
import { BokslutsbilagorPDF } from '@/lib/reports/bokslutsbilagor-pdf-template'
import { resolveUserLabelsFromProfiles } from '@/lib/reports/behandlingshistorik'
import { currentAppVersion } from '@/lib/reports/app-version'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
const BokslutsbilagorQuerySchema = z.object({
period_id: z.string().uuid(),
format: z.enum(['json', 'pdf']).default('json'),
})
/**
* GET /api/reports/bokslutsbilagor?period_id=&format=json|pdf
*
* The bokslutsbilagor pärm for one räkenskapsår: every balance account as of
* the balansdag with balances, specification or stated balance, sign-off,
* underlag files with hashes, and the checklist. Read-only; signer and
* uploader labels resolve through a service-role lookup on `profiles`
* restricted to the ids in the result, like behandlingshistorik.
*/
export const GET = withRouteContext('report.bokslutsbilagor', async (request, ctx) => {
const { supabase, user, companyId, log, requestId } = ctx
const query = validateQuery(request, BokslutsbilagorQuerySchema, { log, operation: 'report.bokslutsbilagor' })
if (!query.success) return query.response
const { period_id: periodId, format } = query.data
try {
const serviceClient = createServiceClient()
const report = await generateBokslutsbilagor(supabase, companyId, periodId, {
userId: user.id,
resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids),
appVersion: currentAppVersion(),
})
if (!report) return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
if (format === 'json') {
return privateNoStore(NextResponse.json({ data: report }))
}
const pdf = await renderToBuffer(BokslutsbilagorPDF({ report }))
const filename = `bokslutsbilagor-${slugifyCompanyName(report.company.name)}-${report.period.end.replace(/-/g, '')}.pdf`
return new NextResponse(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Cache-Control': 'private, no-store',
},
})
} catch (err) {
// Raw message stays server-side: it can carry table names / SQL.
log.error('bokslutsbilagor generation failed', err as Error, { periodId })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId })
}
})