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