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,80 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { BokslutsbilagorPDF } from '../bokslutsbilagor-pdf-template'
import type { BokslutsbilagorReport } from '../bokslutsbilagor-types'
function report(): BokslutsbilagorReport {
const account = (n: number): BokslutsbilagorReport['accounts'][number] => ({
account_key: `manual:${2300 + n}`,
kind: 'manual',
account_number: String(2300 + n),
name: `Skuld ${n} med ett ganska långt namn för radbrytning`,
opening_balance: -100000 - n,
movement: 5000,
closing_balance: -95000 - n,
external_label_sv: 'Saldo enligt underlag (angivet vid signering)',
external_label_en: 'Balance per supporting documents (stated at sign-off)',
external_balance: -95000 - n,
difference: 0,
signoff: {
id: `s${n}`,
through_date: '2026-12-31',
on_balansdag: n % 2 === 0,
external_balance: -95000 - n,
ledger_balance: -95000 - n,
unexplained_difference: 0,
note: n % 3 === 0 ? 'Enligt engagemangsbesked → kontrollerat' : null,
signed_by: 'u1',
signed_by_label: 'yasemin@example.se',
signed_at: '2027-01-10T08:00:00Z',
},
attachments: [
{
id: `a${n}`,
through_date: '2026-12-31',
file_name: `engagemangsbesked-${n}.pdf`,
mime_type: 'application/pdf',
size_bytes: 1234,
sha256: 'ab'.repeat(32),
note: null,
uploaded_by_label: 'yasemin@example.se',
uploaded_at: '2027-01-09T08:00:00Z',
removed_at: n === 1 ? '2027-01-09T09:00:00Z' : null,
removed_reason: n === 1 ? 'fel fil' : null,
},
],
})
return {
company: { name: 'Väla Redovisning AB', org_number: '5592383508' },
period: { id: 'fy-2026', name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' },
generated_at: '2027-01-15T10:00:00Z',
app_version: '1.2.3',
checklist: {
items: [
{ key: 'bank_signed', group: 'avstamning', label_sv: 'Bankkonton avstämda', label_en: 'Bank reconciled', state: 'done', done_at: null, done_by_label: null, note: null },
{ key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager inventerat', label_en: 'Inventory counted', state: 'not_applicable', done_at: '2027-01-06T08:00:00Z', done_by_label: 'yasemin@example.se', note: 'Inget lager' },
],
summary: { total: 2, done: 1, not_applicable: 1, open: 0 },
},
accounts: Array.from({ length: 40 }, (_, i) => account(i)),
summary: { accounts: 40, signed_on_balansdag: 20, signed_other_date: 20, unsigned: 0, attachments: 39 },
}
}
describe('BokslutsbilagorPDF', () => {
it('renders a multi-page pärm without deadlocking and with WinAnsi-safe text', async () => {
const pdf = await renderToBuffer(BokslutsbilagorPDF({ report: report() }))
expect(pdf.length).toBeGreaterThan(5000)
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
// More than one page: the fixed header/footer repeat and no `break` was needed.
expect((pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length).toBeGreaterThan(1)
}, 30000)
it('renders the empty pärm', async () => {
const r = report()
r.accounts = []
r.summary = { accounts: 0, signed_on_balansdag: 0, signed_other_date: 0, unsigned: 0, attachments: 0 }
const pdf = await renderToBuffer(BokslutsbilagorPDF({ report: r }))
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
}, 30000)
})
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const listAccountsMock = vi.fn()
const snapshotMock = vi.fn()
const specMock = vi.fn()
const latestSignoffsMock = vi.fn()
const attachmentsMock = vi.fn()
const checklistMock = vi.fn()
vi.mock('@/lib/reconciliation/service', () => ({
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
}))
vi.mock('@/lib/reconciliation/manual-reconciliation', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/manual-reconciliation')>('@/lib/reconciliation/manual-reconciliation')
return {
...actual,
loadBalanceSheetSnapshot: (...args: unknown[]) => snapshotMock(...args),
loadSpecificationAmounts: (...args: unknown[]) => specMock(...args),
}
})
vi.mock('@/lib/reconciliation/signoff-store', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/signoff-store')>('@/lib/reconciliation/signoff-store')
return { ...actual, getLatestSignoffs: (...args: unknown[]) => latestSignoffsMock(...args) }
})
vi.mock('@/lib/reconciliation/attachments-store', () => ({
listAttachmentRowsInRange: (...args: unknown[]) => attachmentsMock(...args),
}))
vi.mock('@/lib/bokslut/checklist', () => ({
buildBokslutChecklist: (...args: unknown[]) => checklistMock(...args),
}))
import { generateBokslutsbilagor } from '../bokslutsbilagor'
const COMPANY = 'company-1'
const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
function account(overrides: Record<string, unknown>) {
return {
account_key: 'manual:2350',
kind: 'manual',
account_number: '2350',
name: 'Banklån',
currency: 'SEK',
logo_url: null,
source: { type: 'manual', synced_at: null, stale: false },
status: null,
superseded_by: null,
signed_off_through: null,
...overrides,
}
}
function signoffRow(overrides: Record<string, unknown>) {
return {
id: 's1',
account_key: 'manual:2350',
through_date: '2026-12-31',
external_balance: -250000,
ledger_balance: -250000,
unexplained_difference: 0,
note: 'Enligt engagemangsbesked',
signed_by: 'u1',
signed_at: '2027-01-10T08:00:00Z',
reopened_at: null,
reopened_by: null,
reopen_reason: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
for (const m of [listAccountsMock, snapshotMock, specMock, latestSignoffsMock, attachmentsMock, checklistMock]) m.mockReset()
listAccountsMock.mockResolvedValue([
account({ account_key: 'bank:11111111-1111-4111-8111-111111111111', kind: 'bank', account_number: '1930', name: 'Företagskonto' }),
account({ account_key: 'manual:1510', account_number: '1510', name: 'Kundfordringar' }),
account({}),
])
snapshotMock.mockResolvedValue({
period: PERIOD,
as_of: '2026-12-31',
rows: new Map([
['1930', { account_number: '1930', account_name: 'Företagskonto', opening_balance: 50000, movement: 12000, closing_balance: 62000 }],
['1510', { account_number: '1510', account_name: 'Kundfordringar', opening_balance: 8000, movement: 4000, closing_balance: 12000 }],
['2350', { account_number: '2350', account_name: 'Banklån', opening_balance: -260000, movement: 10000, closing_balance: -250000 }],
]),
})
specMock.mockResolvedValue(new Map([['1510', { amount: 11500, unconverted_fx_count: 0 }]]))
latestSignoffsMock.mockResolvedValue(new Map([['bank:11111111-1111-4111-8111-111111111111', signoffRow({ id: 's-bank', account_key: 'bank:11111111-1111-4111-8111-111111111111', through_date: '2026-11-30', external_balance: 61000, ledger_balance: 61000, note: null, signed_by: 'u2' })]]))
attachmentsMock.mockResolvedValue([
{ id: 'a1', account_key: 'manual:2350', through_date: '2026-12-31', file_name: 'engagemangsbesked.pdf', mime_type: 'application/pdf', size_bytes: 100, storage_bucket: 'documents', storage_path: 'x', sha256: 'ab'.repeat(32), note: null, uploaded_by: 'u1', uploaded_at: '2027-01-09T08:00:00Z', removed_at: null, removed_by: null, removed_reason: null },
{ id: 'a2', account_key: 'manual:2350', through_date: '2026-12-31', file_name: 'fel.pdf', mime_type: 'application/pdf', size_bytes: 100, storage_bucket: 'documents', storage_path: 'y', sha256: 'cd'.repeat(32), note: null, uploaded_by: 'u1', uploaded_at: '2027-01-09T08:00:00Z', removed_at: '2027-01-09T09:00:00Z', removed_by: 'u1', removed_reason: 'fel fil' },
])
checklistMock.mockResolvedValue({
period: PERIOD,
items: [{ key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager', label_en: 'Inventory', auto: false, auto_state: null, stored_state: 'done', effective_state: 'done', note: null, done_by: 'u1', done_at: '2027-01-06T08:00:00Z' }],
summary: { total: 1, done: 1, not_applicable: 0, open: 0 },
})
})
describe('generateBokslutsbilagor', () => {
it('builds one bilaga per account from the snapshot, the sign-offs, the specification and the files', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD }) // fiscal period
enqueue({ data: { name: 'Väla Redovisning AB', org_number: '5592383508' } }) // company
enqueue({ data: [signoffRow({})] }) // sign-offs on the balansdag
const report = await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026', {
userId: 'u1',
appVersion: '1.2.3',
resolveUserLabels: async (ids) => new Map(ids.map((id) => [id, `${id}@example.se`])),
})
expect(report).not.toBeNull()
expect(report!.company).toEqual({ name: 'Väla Redovisning AB', org_number: '5592383508' })
expect(report!.accounts.map((a) => a.account_number)).toEqual(['1930', '1510', '2350'])
const [bank, ar, loan] = report!.accounts
// Bank: latest sign-off is not the balansdag, so it is flagged; the outside balance is unknown for the balansdag.
expect(bank).toMatchObject({ closing_balance: 62000, external_balance: null, difference: null })
expect(bank.signoff).toMatchObject({ on_balansdag: false, through_date: '2026-11-30', signed_by_label: 'u2@example.se' })
// AR: system specification, difference against the booked balance.
expect(ar).toMatchObject({ external_balance: 11500, closing_balance: 12000, difference: 500, external_label_sv: 'Kundreskontra, öppna fakturor' })
expect(ar.signoff).toBeNull()
// Loan: stated balance from the balansdag sign-off, one active file, one removed.
expect(loan).toMatchObject({ opening_balance: -260000, movement: 10000, closing_balance: -250000, external_balance: -250000, difference: 0 })
expect(loan.signoff).toMatchObject({ on_balansdag: true, note: 'Enligt engagemangsbesked', signed_by_label: 'u1@example.se' })
expect(loan.attachments.map((a) => [a.file_name, a.removed_at != null])).toEqual([['engagemangsbesked.pdf', false], ['fel.pdf', true]])
expect(report!.summary).toEqual({ accounts: 3, signed_on_balansdag: 1, signed_other_date: 1, unsigned: 1, attachments: 1 })
expect(report!.checklist.items[0]).toMatchObject({ key: 'inventory_valued', state: 'done', done_by_label: 'u1@example.se' })
expect(report!.app_version).toBe('1.2.3')
expect(checklistMock).toHaveBeenCalledWith(supabase, COMPANY, 'u1', 'fy-2026', {})
expect(listAccountsMock).toHaveBeenCalledWith(supabase, COMPANY, { today: '2026-12-31', windowFrom: '2026-01-01', windowTo: '2026-12-31' })
})
it('skips the readiness-derived checklist items without a user, and returns null for a foreign period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: { name: 'X', org_number: null } })
enqueue({ data: [] })
await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026')
expect(checklistMock).toHaveBeenCalledWith(supabase, COMPANY, '', 'fy-2026', { readiness: null })
enqueue({ data: null })
enqueue({ data: null })
expect(await generateBokslutsbilagor(supabase as never, COMPANY, 'nope')).toBeNull()
})
it('survives a failed snapshot, sign-off or attachment read', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: { name: 'X', org_number: null } })
enqueue({ data: null, error: { message: 'boom' } })
snapshotMock.mockRejectedValue(new Error('tb down'))
attachmentsMock.mockRejectedValue(new Error('storage down'))
latestSignoffsMock.mockRejectedValue(new Error('signoffs down'))
const report = await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026', { userId: 'u1' })
expect(report!.accounts).toHaveLength(3)
expect(report!.accounts.every((a) => a.closing_balance === null && a.signoff === null && a.attachments.length === 0)).toBe(true)
expect(specMock).not.toHaveBeenCalled()
})
})
@@ -53,6 +53,11 @@ vi.mock('../journal-register', () => ({
// The bilagor step reads account_reconciliation_attachments through the
// store; an empty list keeps the queued-mock order of these tests intact.
// The pärm step runs the reconciliation and checklist readers; a null report
// keeps the queued-mock order of these tests intact.
vi.mock('../bokslutsbilagor', () => ({
generateBokslutsbilagor: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/reconciliation/attachments-store', () => ({
listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
}))
@@ -0,0 +1,266 @@
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import type { BilagaAccount, BilagaChecklistItem, BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types'
import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik'
/**
* Bokslutsbilagor as a printable pärm: the checklist first, then one bilaga
* per balance account with the balances, what it was reconciled against, the
* sign-off and the underlag files with their hashes. Same layout rules as the
* other report PDFs: bundled Helvetica/Courier (no Font.register), header and
* footer `fixed`, every row `wrap={false}`, and no `break` props (they
* deadlock multi-page renders in @react-pdf/renderer 4).
*/
const INK = '#1a1a1a'
const MUTED = '#666'
const HAIRLINE = '#d4d4d4'
const styles = StyleSheet.create({
page: { paddingTop: 36, paddingHorizontal: 40, paddingBottom: 54, fontSize: 8.5, fontFamily: 'Helvetica', color: INK },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 10,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: HAIRLINE,
},
titleBlock: { flex: 1 },
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 3 },
subtitle: { fontSize: 9.5, color: '#333', marginBottom: 2 },
legal: { fontSize: 8, color: MUTED },
companyInfo: { textAlign: 'right' },
companyName: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 },
companyMeta: { fontSize: 8.5, color: MUTED },
meta: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 8 },
metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 },
metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 },
metaValue: { fontSize: 9 },
sectionHeading: { fontSize: 10.5, fontWeight: 'bold', marginTop: 12, marginBottom: 4, paddingBottom: 3, borderBottomWidth: 0.5, borderBottomColor: '#888' },
sectionNote: { fontSize: 8, color: MUTED, marginBottom: 4 },
row: { flexDirection: 'row', paddingVertical: 2.5, borderBottomWidth: 0.5, borderBottomColor: '#ececec' },
checkState: { width: 70, fontFamily: 'Courier', fontSize: 7.5 },
checkLabel: { flex: 1, paddingRight: 8 },
checkWho: { width: 150, fontSize: 7.5, color: MUTED },
bilaga: { marginTop: 8, paddingTop: 6, borderTopWidth: 0.5, borderTopColor: '#bbb' },
bilagaHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 },
bilagaTitle: { fontSize: 9.5, fontWeight: 'bold' },
bilagaKey: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED },
numbers: { flexDirection: 'row', marginBottom: 3 },
numCell: { width: '20%', paddingRight: 8 },
numLabel: { fontSize: 6.8, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.3 },
numValue: { fontFamily: 'Courier', fontSize: 8.5 },
line: { fontSize: 8, color: '#333', marginBottom: 1.5 },
lineMuted: { fontSize: 7.5, color: MUTED, marginBottom: 1.5 },
fileRow: { flexDirection: 'row', paddingVertical: 1.5 },
fileName: { flex: 1, fontSize: 7.8, paddingRight: 6 },
fileMeta: { width: 200, fontFamily: 'Courier', fontSize: 6.8, color: MUTED },
empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 },
footer: { position: 'absolute', bottom: 22, left: 40, right: 40, borderTopWidth: 0.5, borderTopColor: HAIRLINE, paddingTop: 5, flexDirection: 'row', justifyContent: 'space-between' },
footerText: { fontSize: 7.5, color: '#888' },
})
/** WinAnsi only: arrows, true minus and narrow spaces would drop silently. */
function pdfText(value: string): string {
return value.replace(/→/g, '->').replace(/−/g, '-').replace(/[   ]/g, ' ')
}
const NUMBER = new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function amount(n: number | null): string {
return n == null ? '-' : pdfText(NUMBER.format(n))
}
function formatOrgNumber(orgNumber: string): string {
const cleaned = orgNumber.replace(/\D/g, '')
return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber
}
const STATE_LABEL: Record<BilagaChecklistItem['state'], string> = {
done: '[x] Klart',
not_applicable: '[-] Ej tillämpl.',
open: '[ ] Öppet',
}
const GROUP_LABEL: Record<string, string> = {
avstamning: 'Avstämningar',
periodisering: 'Periodiseringar',
vardering: 'Värdering',
dispositioner: 'Dispositioner och skatt',
kontroll: 'Kontroller',
rapportering: 'Rapportering',
}
function ChecklistRow({ item }: { item: BilagaChecklistItem }) {
const who = item.done_at ? `${item.done_by_label ?? ''} ${formatStockholmTimestamp(item.done_at)}`.trim() : ''
return (
<View style={styles.row} wrap={false}>
<Text style={styles.checkState}>{STATE_LABEL[item.state]}</Text>
<Text style={styles.checkLabel}>
{pdfText(item.label_sv)}
{item.note ? ` (${pdfText(item.note)})` : ''}
</Text>
<Text style={styles.checkWho}>{pdfText(who)}</Text>
</View>
)
}
function Bilaga({ account, balansdag }: { account: BilagaAccount; balansdag: string }) {
const s = account.signoff
const signLine = !s
? 'Ej signerad.'
: s.on_balansdag
? `Signerad per ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)})${s.note ? `: ${s.note}` : ''}`
: `Senast signerad t.o.m. ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)}), inte per balansdagen ${balansdag}${s.note ? `: ${s.note}` : ''}`
const active = account.attachments.filter((a) => !a.removed_at)
const removed = account.attachments.filter((a) => a.removed_at)
return (
<View style={styles.bilaga}>
<View style={styles.bilagaHead} wrap={false}>
<Text style={styles.bilagaTitle}>
{account.account_number} {pdfText(account.name)}
</Text>
<Text style={styles.bilagaKey}>{account.account_key}</Text>
</View>
<View style={styles.numbers} wrap={false}>
<View style={styles.numCell}>
<Text style={styles.numLabel}>Ingående balans</Text>
<Text style={styles.numValue}>{amount(account.opening_balance)}</Text>
</View>
<View style={styles.numCell}>
<Text style={styles.numLabel}>Förändring</Text>
<Text style={styles.numValue}>{amount(account.movement)}</Text>
</View>
<View style={styles.numCell}>
<Text style={styles.numLabel}>Utgående balans</Text>
<Text style={styles.numValue}>{amount(account.closing_balance)}</Text>
</View>
<View style={styles.numCell}>
<Text style={styles.numLabel}>Enligt underlag</Text>
<Text style={styles.numValue}>{amount(account.external_balance)}</Text>
</View>
<View style={styles.numCell}>
<Text style={styles.numLabel}>Differens</Text>
<Text style={styles.numValue}>{amount(account.difference)}</Text>
</View>
</View>
<Text style={styles.lineMuted} wrap={false}>
Underlag: {pdfText(account.external_label_sv)}
</Text>
<Text style={styles.line} wrap={false}>
{pdfText(signLine)}
</Text>
{active.length === 0 ? (
<Text style={styles.lineMuted} wrap={false}>
Inga bifogade filer.
</Text>
) : (
active.map((a) => (
<View key={a.id} style={styles.fileRow} wrap={false}>
<Text style={styles.fileName}>
{pdfText(a.file_name)}
{a.note ? ` (${pdfText(a.note)})` : ''}
{a.through_date !== balansdag ? ` per ${a.through_date}` : ''}
</Text>
<Text style={styles.fileMeta}>
sha256 {a.sha256.slice(0, 16)} · {formatStockholmTimestamp(a.uploaded_at)}
</Text>
</View>
))
)}
{removed.map((a) => (
<View key={a.id} style={styles.fileRow} wrap={false}>
<Text style={[styles.fileName, { color: MUTED }]}>
Borttagen: {pdfText(a.file_name)}
{a.removed_reason ? ` (${pdfText(a.removed_reason)})` : ''}
</Text>
<Text style={styles.fileMeta}>{a.removed_at ? formatStockholmTimestamp(a.removed_at) : ''}</Text>
</View>
))}
</View>
)
}
export interface BokslutsbilagorPDFProps {
report: BokslutsbilagorReport
}
export function BokslutsbilagorPDF({ report }: BokslutsbilagorPDFProps) {
const generated = formatStockholmTimestamp(report.generated_at)
const groups = [...new Set(report.checklist.items.map((i) => i.group))]
return (
<Document title={`Bokslutsbilagor ${report.period.name}`} author={report.company.name} subject="Bokslutsbilagor per balansdagen">
<Page size="A4" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>Bokslutsbilagor</Text>
<Text style={styles.subtitle}>
{report.period.name} ({report.period.start} till {report.period.end}) · Balansdag {report.period.end}
</Text>
<Text style={styles.legal}>Avstämning och dokumentation per balanspost · Tider i Europe/Stockholm</Text>
</View>
<View style={styles.companyInfo}>
{report.company.name ? <Text style={styles.companyName}>{pdfText(report.company.name)}</Text> : null}
{report.company.org_number ? <Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(report.company.org_number)}</Text> : null}
</View>
</View>
<View style={styles.meta}>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Genererad</Text>
<Text style={styles.metaValue}>{generated}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Programversion</Text>
<Text style={styles.metaValue}>{report.app_version ?? 'okänd'}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Balanskonton</Text>
<Text style={styles.metaValue}>
{String(report.summary.accounts)} · signerade per balansdagen {String(report.summary.signed_on_balansdag)} · annan dag{' '}
{String(report.summary.signed_other_date)} · osignerade {String(report.summary.unsigned)}
</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Bifogade filer</Text>
<Text style={styles.metaValue}>{String(report.summary.attachments)}</Text>
</View>
</View>
<Text style={styles.sectionHeading}>Bokslutschecklista</Text>
<Text style={styles.sectionNote}>
{report.checklist.summary.done} klara, {report.checklist.summary.not_applicable} ej tillämpliga, {report.checklist.summary.open} öppna av{' '}
{report.checklist.summary.total}. Steg som systemet bedömer själv visas som de stod när pärmen genererades.
</Text>
{groups.map((group) => (
<View key={group}>
<Text style={[styles.lineMuted, { marginTop: 4 }]}>{GROUP_LABEL[group] ?? group}</Text>
{report.checklist.items.filter((i) => i.group === group).map((item) => <ChecklistRow key={item.key} item={item} />)}
</View>
))}
<Text style={styles.sectionHeading}>Bilagor per balanskonto</Text>
<Text style={styles.sectionNote}>
Bokfört enligt saldobalansen per balansdagen. Enligt underlag: reskontra eller beräkning där systemet har en, annars det saldo som angavs
eller hämtades vid signeringen. Filer identifieras med SHA-256; borttagna filer finns kvar i arkivet.
</Text>
{report.accounts.length === 0 ? (
<Text style={styles.empty}>Inga balanskonton med saldo eller rörelse i perioden.</Text>
) : (
report.accounts.map((account) => <Bilaga key={account.account_key} account={account} balansdag={report.period.end} />)
)}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{pdfText(report.company.name)}
{report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''}
{' · Bokslutsbilagor '}
{report.period.name}
</Text>
<Text style={styles.footerText} render={({ pageNumber, totalPages }) => `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`} />
</View>
</Page>
</Document>
)
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Bokslutsbilagor: the pärm a redovisningskonsult keeps per räkenskapsår
* (Reko 140/760/765). One bilaga per balance account as of the balansdag:
* the booked balance and how it moved, what it was reconciled against, who
* signed it and when, and the underlag files with their content hashes. The
* checklist rides along as the first page. Types live apart from the
* generator so the report view does not pull the PDF renderer into the
* client bundle.
*/
import type { ChecklistState } from '@/lib/bokslut/checklist'
export interface BilagaSignoff {
id: string
through_date: string
/** True when through_date is the balansdag; false when the latest sign-off ends earlier or later. */
on_balansdag: boolean
external_balance: number | null
ledger_balance: number | null
unexplained_difference: number | null
note: string | null
signed_by: string
signed_by_label: string
signed_at: string
}
export interface BilagaAttachment {
id: string
through_date: string
file_name: string
mime_type: string
size_bytes: number
sha256: string
note: string | null
uploaded_by_label: string
uploaded_at: string
removed_at: string | null
removed_reason: string | null
}
export interface BilagaAccount {
account_key: string
kind: 'bank' | 'skattekonto' | 'manual'
account_number: string
name: string
/** From the trial balance through the balansdag; null when the account has no row in the period. */
opening_balance: number | null
movement: number | null
closing_balance: number | null
/** What the account was reconciled against: system specification, stated balance, or the feed's balance at sign-off. */
external_label_sv: string
external_label_en: string
external_balance: number | null
difference: number | null
signoff: BilagaSignoff | null
attachments: BilagaAttachment[]
}
export interface BilagaChecklistItem {
key: string
group: string
label_sv: string
label_en: string
state: ChecklistState
done_at: string | null
done_by_label: string | null
note: string | null
}
export interface BokslutsbilagorReport {
company: { name: string; org_number: string | null }
period: { id: string; name: string; start: string; end: string }
generated_at: string
app_version: string | null
checklist: {
items: BilagaChecklistItem[]
summary: { total: number; done: number; not_applicable: number; open: number }
}
accounts: BilagaAccount[]
summary: {
accounts: number
signed_on_balansdag: number
signed_other_date: number
unsigned: number
attachments: number
}
}
+221
View File
@@ -0,0 +1,221 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
import {
loadBalanceSheetSnapshot,
loadSpecificationAmounts,
SPECIFICATION_PROVIDERS,
type BalanceSheetSnapshot,
} from '@/lib/reconciliation/manual-reconciliation'
import { getLatestSignoffs, mapSignoffRow } from '@/lib/reconciliation/signoff-store'
import { listAttachmentRowsInRange, type AttachmentRow } from '@/lib/reconciliation/attachments-store'
import type { ReconciliationAccount, ReconciliationSignoff } from '@/lib/reconciliation/schemas'
import { buildBokslutChecklist } from '@/lib/bokslut/checklist'
import type { BilagaAccount, BilagaAttachment, BilagaSignoff, BokslutsbilagorReport } from './bokslutsbilagor-types'
const log = createLogger('reports/bokslutsbilagor')
export interface BokslutsbilagorOptions {
/** The acting user; without one the checklist skips the readiness-derived items (archive runs have no user). */
userId?: string | null
/** Resolves auth user ids to display labels (email / name); defaults to the id itself. */
resolveUserLabels?: (ids: string[]) => Promise<Map<string, string>>
appVersion?: string | null
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
const KIND_ORDER: Record<ReconciliationAccount['kind'], number> = { bank: 0, skattekonto: 1, manual: 2 }
function externalLabels(account: ReconciliationAccount, hasSpecification: boolean): { sv: string; en: string } {
if (account.kind === 'bank') return { sv: 'Banken (saldo vid signering)', en: 'The bank (balance at sign-off)' }
if (account.kind === 'skattekonto') return { sv: 'Skatteverket (saldo vid signering)', en: 'Skatteverket (balance at sign-off)' }
if (hasSpecification) {
const p = SPECIFICATION_PROVIDERS[account.account_number]
return { sv: p.label_sv, en: p.label_en }
}
return { sv: 'Saldo enligt underlag (angivet vid signering)', en: 'Balance per supporting documents (stated at sign-off)' }
}
/**
* The pärm for one räkenskapsår. Null when the period is not this company's.
* One trial-balance read, one reconciliation list, one sign-off read per
* flavour, one attachment read: no per-account status recomputation.
*/
export async function generateBokslutsbilagor(
supabase: SupabaseClient,
companyId: string,
periodId: string,
options: BokslutsbilagorOptions = {},
): Promise<BokslutsbilagorReport | null> {
const [{ data: periodData, error: periodError }, { data: companyData }] = await Promise.all([
supabase.from('fiscal_periods').select('id, name, period_start, period_end').eq('id', periodId).eq('company_id', companyId).maybeSingle(),
supabase.from('companies').select('name, org_number').eq('id', companyId).maybeSingle(),
])
if (periodError) throw new Error(`Kunde inte hämta räkenskapsår: ${periodError.message}`)
const period = periodData as PeriodRow | null
if (!period) return null
const company = (companyData as { name: string | null; org_number: string | null } | null) ?? { name: null, org_number: null }
const balansdag = period.period_end
const [accounts, snapshot, balansdagSignoffs, latestSignoffs, attachmentRows, checklist] = await Promise.all([
listReconciliationAccounts(supabase, companyId, { today: balansdag, windowFrom: period.period_start, windowTo: balansdag }),
loadBalanceSheetSnapshot(supabase, companyId, balansdag).catch((err): BalanceSheetSnapshot | null => {
log.warn('balance snapshot failed', { companyId, periodId, error: String(err) })
return null
}),
signoffsOn(supabase, companyId, balansdag),
getLatestSignoffs(supabase, companyId).catch(() => new Map<string, ReconciliationSignoff | null>()),
listAttachmentRowsInRange(supabase, companyId, period.period_start, balansdag, { includeRemoved: true }).catch((err): AttachmentRow[] => {
log.warn('attachment read failed', { companyId, periodId, error: String(err) })
return []
}),
buildBokslutChecklist(supabase, companyId, options.userId ?? '', periodId, options.userId ? {} : { readiness: null }),
])
const specifications = snapshot
? await loadSpecificationAmounts(
supabase,
companyId,
snapshot,
new Set(accounts.filter((a) => a.kind === 'manual' && SPECIFICATION_PROVIDERS[a.account_number]).map((a) => a.account_number)),
)
: new Map()
const attachmentsByKey = new Map<string, AttachmentRow[]>()
for (const row of attachmentRows) {
attachmentsByKey.set(row.account_key, [...(attachmentsByKey.get(row.account_key) ?? []), row])
}
const userIds = new Set<string>()
for (const s of balansdagSignoffs.values()) userIds.add(s.signed_by)
for (const s of latestSignoffs.values()) if (s) userIds.add(s.signed_by)
for (const a of attachmentRows) userIds.add(a.uploaded_by)
for (const i of checklist?.items ?? []) if (i.done_by) userIds.add(i.done_by)
let labels = new Map<string, string>()
if (options.resolveUserLabels && userIds.size > 0) {
try {
labels = await options.resolveUserLabels([...userIds])
} catch (err) {
log.warn('user label resolution failed', { companyId, error: String(err) })
}
}
const label = (id: string) => labels.get(id) ?? id
const bilagor: BilagaAccount[] = [...accounts]
.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.account_number.localeCompare(b.account_number))
.map((account): BilagaAccount => {
const row = snapshot?.rows.get(account.account_number) ?? null
const spec = account.kind === 'manual' ? specifications.get(account.account_number) : undefined
const onDay = balansdagSignoffs.get(account.account_key) ?? null
const latest = latestSignoffs.get(account.account_key) ?? null
const chosen = onDay ?? latest
const signoff: BilagaSignoff | null = chosen
? {
id: chosen.id,
through_date: chosen.through_date,
on_balansdag: chosen.through_date === balansdag,
external_balance: chosen.external_balance,
ledger_balance: chosen.ledger_balance,
unexplained_difference: chosen.unexplained_difference,
note: chosen.note,
signed_by: chosen.signed_by,
signed_by_label: label(chosen.signed_by),
signed_at: chosen.signed_at,
}
: null
// The outside side: the system specification for the reskontra accounts,
// else what was recorded at the balansdag sign-off (the feed's balance,
// or the balance the signer stated).
const external = spec ? spec.amount : onDay ? onDay.external_balance : null
const closing = row ? row.closing_balance : (onDay?.ledger_balance ?? null)
const difference = external != null && closing != null ? roundOre(closing - external) : null
const labelsFor = externalLabels(account, Boolean(spec))
return {
account_key: account.account_key,
kind: account.kind,
account_number: account.account_number,
name: account.name,
opening_balance: row?.opening_balance ?? null,
movement: row?.movement ?? null,
closing_balance: closing,
external_label_sv: labelsFor.sv,
external_label_en: labelsFor.en,
external_balance: external,
difference,
signoff,
attachments: (attachmentsByKey.get(account.account_key) ?? []).map(
(a): BilagaAttachment => ({
id: a.id,
through_date: a.through_date,
file_name: a.file_name,
mime_type: a.mime_type,
size_bytes: a.size_bytes,
sha256: a.sha256,
note: a.note,
uploaded_by_label: label(a.uploaded_by),
uploaded_at: a.uploaded_at,
removed_at: a.removed_at,
removed_reason: a.removed_reason,
}),
),
}
})
const signedOnDay = bilagor.filter((b) => b.signoff?.on_balansdag).length
const signedOther = bilagor.filter((b) => b.signoff && !b.signoff.on_balansdag).length
return {
company: { name: company.name ?? '', org_number: company.org_number ?? null },
period: { id: period.id, name: period.name, start: period.period_start, end: period.period_end },
generated_at: new Date().toISOString(),
app_version: options.appVersion ?? null,
checklist: {
items: (checklist?.items ?? []).map((i) => ({
key: i.key,
group: i.group,
label_sv: i.label_sv,
label_en: i.label_en,
state: i.effective_state,
done_at: i.done_at,
done_by_label: i.done_by ? label(i.done_by) : null,
note: i.note,
})),
summary: checklist?.summary ?? { total: 0, done: 0, not_applicable: 0, open: 0 },
},
accounts: bilagor,
summary: {
accounts: bilagor.length,
signed_on_balansdag: signedOnDay,
signed_other_date: signedOther,
unsigned: bilagor.length - signedOnDay - signedOther,
attachments: attachmentRows.filter((a) => !a.removed_at).length,
},
}
}
/** Active sign-offs whose through_date is exactly the balansdag, keyed by account. */
async function signoffsOn(supabase: SupabaseClient, companyId: string, throughDate: string): Promise<Map<string, ReconciliationSignoff>> {
const out = new Map<string, ReconciliationSignoff>()
const { data, error } = await supabase
.from('account_reconciliations')
.select('id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason')
.eq('company_id', companyId)
.eq('through_date', throughDate)
.is('reopened_at', null)
if (error) {
log.warn('balansdag sign-off read failed', { companyId, throughDate, error: error.message })
return out
}
for (const row of (data ?? []) as Parameters<typeof mapSignoffRow>[0][]) {
const mapped = mapSignoffRow(row)
out.set(mapped.account_key, mapped)
}
return out
}
+15
View File
@@ -336,6 +336,21 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
searchTerms:
'behandlingshistorik audit trail audit log händelselogg ändringslogg logg historik vem gjorde vad processing history revision systemdokumentation',
},
{
// Bokslutsbilagor (Reko 140/760/765): the pärm per räkenskapsår, one
// bilaga per balance account as of the balansdag with balances, the
// specification or stated balance, the sign-off and the underlag files.
// Whole period only: a bilaga is per balansdag, not per date range.
slug: 'bokslutsbilagor',
labelKey: 'name_bokslutsbilagor',
descKey: 'desc_bokslutsbilagor',
category: 'export',
params: 'fiscal',
exports: ['pdf'],
libraryOnly: true,
searchTerms:
'bokslutsbilagor bilagor bilaga bokslutspärm pärm avstämning avstämningar underlag signering reko balanskonton specifikation kontoutdrag engagemangsbesked checklista',
},
]
/** Reports that take a fiscal period + optional date sub-range. */
+33
View File
@@ -10,6 +10,7 @@ import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store'
import { generateBokslutsbilagor } from './bokslutsbilagor'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import {
@@ -153,6 +154,7 @@ export async function generateFullArchive(
const reports = await generatePeriodReports(supabase, companyId, period)
const periodFolder = rapporterFolder.folder(periodLabel(period))!
writeReports(periodFolder, reports)
await writeBokslutsbilagor(periodFolder, supabase, companyId, period.id)
})
)
}
@@ -168,6 +170,7 @@ export async function generateFullArchive(
const reports = await generatePeriodReports(supabase, companyId, period)
const rapporter = zip.folder('rapporter')!
writeReports(rapporter, reports)
await writeBokslutsbilagor(rapporter, supabase, companyId, period.id)
}
if (options.include_documents !== false) {
@@ -556,6 +559,36 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
/**
* The bokslutsbilagor pärm for one period, as JSON and PDF next to the other
* reports. Archive runs have no acting user, so the checklist's
* readiness-derived items are left as stored. Best-effort like the reports:
* a failure is logged into the folder rather than aborting the archive.
*/
async function writeBokslutsbilagor(
folder: JSZip,
supabase: SupabaseClient,
companyId: string,
periodId: string
): Promise<void> {
try {
const report = await generateBokslutsbilagor(supabase, companyId, periodId, { appVersion: currentAppVersion() })
if (!report) return
folder.file('bokslutsbilagor.json', JSON.stringify(report, null, 2))
// The renderer and the template load on demand: the template registers
// styles at import time, and this module is imported far more widely
// than the pärm is rendered (tests stub @react-pdf/renderer partially).
const [{ BokslutsbilagorPDF }, { renderToBuffer }] = await Promise.all([
import('./bokslutsbilagor-pdf-template'),
import('@react-pdf/renderer'),
])
const pdf = await renderToBuffer(BokslutsbilagorPDF({ report }))
folder.file('bokslutsbilagor.pdf', new Uint8Array(pdf))
} catch (err) {
folder.file('bokslutsbilagor.error.txt', err instanceof Error ? err.message : 'Unknown error')
}
}
interface ReconciliationAttachmentManifestEntry {
attachment_id: string
account_key: string