feat(reports): behandlingshistorik as PDF + systemdokumentation pointer and version (#1790)

PR 2 of the behandlingshistorik plan (stacked on #1787).

- lib/reports/behandlingshistorik-pdf-template.tsx: landscape A4 react-pdf
  document. Fixed header (räkenskapsår, urval, legal reference, company) and
  footer (page x of y, generated in Europe/Stockholm), repeated table header,
  wrap={false} rows, no `break` props. Two sections in the order the reader
  needs them: "Ändringar i bokföringssystemet" (p. 9.16 second paragraph)
  then "Bokföringsposter i registreringsordning" (first paragraph). Meta row:
  generated, programversion, antal händelser, källor. Details as one wrapped
  paragraph per row (real-data render 371 events: 1.5 s, 23 pages). Glyphs the
  bundled Helvetica lacks (arrow, true minus) are mapped to ASCII.
- GET /api/reports/behandlingshistorik?format=pdf with a 4 000-event guard
  (413 REPORT_PDF_TOO_LARGE, CSV/XLSX remain complete); PDF first in the
  export menu; catalog exports pdf+xlsx.
- lib/reports/app-version.ts shared by the route and the archive:
  revision/systemdokumentation.json now carries system.version and a
  behandlingshistorik block (where and how it is produced, p. 9.15); the
  shipped systemdokumentation template §9.3 points at Rapporter >
  Behandlingshistorik (PDF/CSV/Excel) as well as the backup ZIP.
- Settings values that are objects render as "key: value" pairs in every
  format; report carries category_filter so the document states its urval.
- Tests: 4 PDF template tests (valid PDF, empty report, filtered range,
  220-row pagination), route pdf 200 + 413, route "unknown format" moved off
  pdf. Prod read-only render verified visually (header, sections, paging).


Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi

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-21 16:47:19 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4be51aae67
commit 99a872987e
13 changed files with 481 additions and 11 deletions
@@ -29,16 +29,29 @@ vi.mock('@/lib/reports/behandlingshistorik', () => ({
resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()),
}))
// The PDF layout itself is covered by the template test; here the renderer is
// a stub so the route test stays fast and asserts only the HTTP contract.
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 stub', 'utf-8')),
}))
vi.mock('@/lib/reports/behandlingshistorik-pdf-template', () => ({
BehandlingshistorikPDF: vi.fn().mockReturnValue({ type: 'Document' }),
}))
import { renderToBuffer } from '@react-pdf/renderer'
import {
generateBehandlingshistorik,
buildBehandlingshistorikExport,
resolveUserLabelsFromProfiles,
} from '@/lib/reports/behandlingshistorik'
import { GET } from '../route'
import { BehandlingshistorikPDF } from '@/lib/reports/behandlingshistorik-pdf-template'
import { GET, PDF_EVENT_LIMIT } from '../route'
const mockGenerate = vi.mocked(generateBehandlingshistorik)
const mockExport = vi.mocked(buildBehandlingshistorikExport)
const mockResolve = vi.mocked(resolveUserLabelsFromProfiles)
const mockRender = vi.mocked(renderToBuffer)
const mockPdfTemplate = vi.mocked(BehandlingshistorikPDF)
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
@@ -98,7 +111,7 @@ describe('GET /api/reports/behandlingshistorik', () => {
})
it('returns 400 on an unknown format', async () => {
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=docx')
expect(res.status).toBe(400)
})
@@ -181,4 +194,27 @@ describe('GET /api/reports/behandlingshistorik', () => {
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
expect(JSON.stringify(body)).not.toContain('audit_log')
})
it('streams the PDF with an attachment filename and renders the template with the report', async () => {
mockGenerate.mockResolvedValue(sampleReport)
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('attachment')
expect(res.headers.get('Content-Disposition')).toContain('behandlingshistorik-testbolaget-ab-20261231.pdf')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockPdfTemplate).toHaveBeenCalledWith({ report: sampleReport })
expect(mockRender).toHaveBeenCalledTimes(1)
const text = await res.text()
expect(text.startsWith('%PDF-')).toBe(true)
})
it('refuses the PDF with 413 when the report exceeds the render limit, without rendering', async () => {
mockGenerate.mockResolvedValue({ ...sampleReport, total_events: PDF_EVENT_LIMIT + 1 })
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
expect(res.status).toBe(413)
const body = await res.json()
expect(body.error.code).toBe('REPORT_PDF_TOO_LARGE')
expect(mockRender).not.toHaveBeenCalled()
})
})
+28 -6
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { BehandlingshistorikQuerySchema } from '@/lib/api/schemas'
@@ -7,11 +8,22 @@ import { privateNoStore } from '@/lib/api/private-no-store'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { currentAppVersion } from '@/lib/reports/app-version'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import {
buildBehandlingshistorikExport,
generateBehandlingshistorik,
resolveUserLabelsFromProfiles,
} from '@/lib/reports/behandlingshistorik'
import { BehandlingshistorikPDF } from '@/lib/reports/behandlingshistorik-pdf-template'
/**
* @react-pdf/renderer lays out every row on the CPU (measured ~25 ms per event
* on a 371-event year); beyond this many events the render approaches the
* function timeout. Larger years are served as CSV/XLSX (complete, instant)
* and the PDF is refused with 413.
*/
export const PDF_EVENT_LIMIT = 4000
/**
* GET /api/reports/behandlingshistorik
@@ -28,12 +40,6 @@ import {
* restricted to the user ids that appear in the result.
*/
/** Running build identifier, stamped on the report (p. 9.16: program version). */
function currentAppVersion(): string | null {
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_BUILD_ID || ''
return sha ? sha.slice(0, 12) : null
}
export const GET = withRouteContext('report.behandlingshistorik', async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
@@ -87,6 +93,22 @@ export const GET = withRouteContext('report.behandlingshistorik', async (request
return privateNoStore(NextResponse.json({ data: report }))
}
if (format === 'pdf') {
if (report.total_events > PDF_EVENT_LIMIT) {
return errorResponseFromCode('REPORT_PDF_TOO_LARGE', log, { requestId })
}
const pdf = await renderToBuffer(BehandlingshistorikPDF({ report }))
const date = report.mode === 'fiscal_year' ? report.period.end : report.range.to
const filename = `behandlingshistorik-${slugifyCompanyName(report.company.name)}-${date.replace(/-/g, '')}.pdf`
return new NextResponse(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Cache-Control': 'private, no-store',
},
})
}
const file = buildBehandlingshistorikExport(report, format)
return new NextResponse(new Uint8Array(file.buffer), {
headers: {
@@ -130,6 +130,7 @@ export function BehandlingshistorikView({
</div>
<ReportExportMenu
items={[
{ format: 'pdf', href: `/api/reports/behandlingshistorik?${query}&format=pdf` },
{ format: 'xlsx', href: `/api/reports/behandlingshistorik?${query}&format=xlsx` },
{ format: 'csv', href: `/api/reports/behandlingshistorik?${query}&format=csv` },
]}
+1 -1
View File
@@ -2507,7 +2507,7 @@ export const BehandlingshistorikQuerySchema = z.object({
category: z
.enum(['verifikation', 'kontoplan', 'installningar', 'period', 'import', 'atkomst', 'ovrigt'])
.optional(),
format: z.enum(['json', 'csv', 'xlsx']).default('json'),
format: z.enum(['json', 'csv', 'xlsx', 'pdf']).default('json'),
})
// ============================================================
+5
View File
@@ -1544,6 +1544,11 @@ const REPORT: Record<string, StructuredErrorEntry> = {
message_sv: 'Rapporten kunde inte genereras.',
message_en: 'Failed to generate the report.',
},
REPORT_PDF_TOO_LARGE: {
httpStatus: 413,
message_sv: 'Rapporten är för stor för PDF. Ladda ner den som CSV eller Excel i stället.',
message_en: 'The report is too large for PDF. Download it as CSV or Excel instead.',
},
}
const VAT_REPORT: Record<string, StructuredErrorEntry> = {
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { BehandlingshistorikPDF, pdfText } from '../behandlingshistorik-pdf-template'
import type { BehandlingshistorikEvent, BehandlingshistorikReport } from '../behandlingshistorik-types'
// Real @react-pdf/renderer layout is CPU-heavy; give it room on a saturated runner.
const RENDER_TIMEOUT = 30_000
function event(overrides: Partial<BehandlingshistorikEvent>): BehandlingshistorikEvent {
return {
id: `ev-${Math.random().toString(36).slice(2, 8)}`,
occurred_at: '2026-03-10T09:30:00.000Z',
category: 'verifikation',
code: 'journal_entry.committed',
event: 'Verifikation bokförd',
object: 'A12',
actor: { type: 'user', user_id: 'user-1', label: 'anna@example.se' },
details: ['Datum: 2026-03-09', 'Text: Hyra mars', 'Källa: Manuell'],
source: 'journal_entries',
count: 1,
...overrides,
}
}
function report(overrides: Partial<BehandlingshistorikReport> = {}): BehandlingshistorikReport {
const events = overrides.events ?? [
event({ id: 'a1', category: 'kontoplan', code: 'account.created.bulk', event: 'Kontoplan upplagd', object: '41 konton', details: ['Konton 1510 till 8410'], source: 'audit_log', count: 41, occurred_at: '2026-01-05T08:00:00.000Z' }),
event({ id: 'a2', category: 'installningar', code: 'settings.updated', event: 'Företagsinställningar ändrade', object: null, details: ['Momsperiod: Kvartal → Helår'], source: 'audit_log', occurred_at: '2026-02-01T08:00:00.000Z' }),
event({ id: 'e1' }),
event({ id: 'e2', object: 'A13', code: 'journal_entry.reversed', event: 'Verifikation makulerad (storno)', actor: { type: 'api_key', user_id: null, label: 'API-nyckel: Revisorn' }, occurred_at: '2026-04-02T10:00:00.000Z' }),
]
const by_category = { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 }
for (const e of events) by_category[e.category] += 1
return {
company: { name: 'Testbolaget AB', org_number: '5566778899' },
period: { id: 'p', name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' },
range: { from: '2026-01-01', to: '2026-12-31' },
mode: 'fiscal_year',
generated_at: '2026-08-21T12:00:00.000Z',
app_version: 'abc123def456',
total_events: events.length,
by_category,
events,
category_filter: null,
...overrides,
}
}
describe('pdfText', () => {
it('maps glyphs the bundled Helvetica lacks to ASCII', () => {
expect(pdfText('Momsperiod: Kvartal → Helår')).toBe('Momsperiod: Kvartal -> Helår')
expect(pdfText('−1 200,00')).toBe('-1 200,00')
expect(pdfText('åäö ÅÄÖ … ·')).toBe('åäö ÅÄÖ … ·')
})
})
describe('BehandlingshistorikPDF', () => {
it(
'renders a valid PDF with both sections',
async () => {
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report() }))
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
expect(buffer.length).toBeGreaterThan(1000)
},
RENDER_TIMEOUT,
)
it(
'renders an empty report (both sections show the empty line) and a category-filtered date range',
async () => {
const empty = await renderToBuffer(
BehandlingshistorikPDF({
report: report({ events: [], total_events: 0, by_category: { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 } }),
}),
)
expect(empty.slice(0, 5).toString()).toBe('%PDF-')
const filtered = await renderToBuffer(
BehandlingshistorikPDF({
report: report({ mode: 'date_range', range: { from: '2026-03-01', to: '2026-03-31' }, category_filter: ['verifikation'], app_version: null }),
}),
)
expect(filtered.slice(0, 5).toString()).toBe('%PDF-')
},
RENDER_TIMEOUT,
)
it(
'paginates a long report without splitting rows (no break props: must not hang)',
async () => {
const events = Array.from({ length: 220 }, (_, i) =>
event({
id: `e${i}`,
object: `A${i + 1}`,
occurred_at: new Date(Date.parse('2026-01-01T08:00:00.000Z') + i * 3_600_000).toISOString(),
details: ['Datum: 2026-01-01', `Text: Rad ${i} med en ganska lång beskrivning som ska radbrytas i detaljkolumnen`, 'Källa: Banktransaktion'],
}),
)
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report({ events, total_events: events.length }) }))
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
// A 220-row landscape table is several pages; /Type /Page objects prove pagination happened.
const pages = (buffer.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length
expect(pages).toBeGreaterThan(3)
},
RENDER_TIMEOUT,
)
})
+10
View File
@@ -0,0 +1,10 @@
/**
* Running software version, stamped on räkenskapsinformation that BFNAR 2013:2
* p. 9.16 second paragraph wants dated: the behandlingshistorik report and the
* systemdokumentation in the archive. Vercel inlines the commit SHA at build;
* self-hosted builds without it report null rather than a made-up value.
*/
export function currentAppVersion(): string | null {
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_BUILD_ID || ''
return sha ? sha.slice(0, 12) : null
}
@@ -0,0 +1,263 @@
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import {
BEHANDLINGSHISTORIK_CATEGORIES,
BEHANDLINGSHISTORIK_CATEGORY_LABELS,
type BehandlingshistorikEvent,
type BehandlingshistorikReport,
} from '@/lib/reports/behandlingshistorik-types'
import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik'
/**
* Behandlingshistorik as a printable document (BFL 5 kap. 11 §, BFNAR 2013:2
* punkt 9.16). Two sections in the order the law lists them in reverse of how
* a reader scans: the system changes first (short, the context), then the
* bokföringsposter in registreringsordning (long, the body).
*
* Layout rules shared with the other report PDFs: Helvetica/Courier (bundled
* standard fonts, so no Font.register), header + footer + table header
* `fixed` so they repeat on every page, every row `wrap={false}` so a row is
* never split across pages, and no `break` props (they deadlock multi-page
* renders in @react-pdf/renderer 4). Landscape: the details column needs the
* width.
*/
const INK = '#1a1a1a'
const MUTED = '#666'
const HAIRLINE = '#d4d4d4'
const styles = StyleSheet.create({
page: {
paddingTop: 36,
paddingHorizontal: 36,
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: 10,
},
metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 },
metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 },
metaValue: { fontSize: 9 },
summary: { fontSize: 8.5, color: '#333', marginBottom: 10 },
tableHeader: {
flexDirection: 'row',
paddingVertical: 4,
borderBottomWidth: 0.5,
borderBottomColor: INK,
marginBottom: 2,
},
tableHeaderText: {
fontSize: 7.5,
fontWeight: 'bold',
color: '#444',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
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',
},
// Column widths live in one place so the header and the rows line up; the
// time column gets Courier only in the body (the header stays Helvetica).
hdrTime: { width: 100, paddingRight: 6 },
hdrEvent: { width: 190, paddingRight: 8 },
hdrActor: { width: 140, paddingRight: 8 },
hdrDetails: { flex: 1 },
colTime: { width: 100, fontFamily: 'Courier', fontSize: 7.5, color: MUTED, paddingRight: 6 },
colEvent: { width: 190, paddingRight: 8 },
colActor: { width: 140, paddingRight: 8, color: '#333' },
colDetails: { flex: 1 },
eventLabel: { fontSize: 8.5 },
objectLabel: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED, marginTop: 1 },
detail: { fontSize: 7.5, color: '#444', marginBottom: 1 },
empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 },
footer: {
position: 'absolute',
bottom: 22,
left: 36,
right: 36,
borderTopWidth: 0.5,
borderTopColor: HAIRLINE,
paddingTop: 5,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: { fontSize: 7.5, color: '#888' },
})
/**
* The bundled Helvetica/Courier AFM fonts only carry WinAnsi glyphs: an arrow
* or a true minus would be dropped silently. Map them to ASCII for print.
*/
export function pdfText(value: string): string {
return value.replace(/→/g, '->').replace(/−/g, '-')
}
function formatOrgNumber(orgNumber: string): string {
const cleaned = orgNumber.replace(/\D/g, '')
return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber
}
function EventRow({ event }: { event: BehandlingshistorikEvent }) {
return (
<View style={styles.row} wrap={false}>
<Text style={styles.colTime}>{formatStockholmTimestamp(event.occurred_at)}</Text>
<View style={styles.colEvent}>
<Text style={styles.eventLabel}>{pdfText(event.event)}</Text>
{event.object ? <Text style={styles.objectLabel}>{pdfText(event.object)}</Text> : null}
</View>
<Text style={styles.colActor}>{pdfText(event.actor.label)}</Text>
<View style={styles.colDetails}>
{event.details.length > 0 ? (
// One wrapped paragraph rather than one Text per line: half the
// layout nodes (render time) and roughly twice the rows per page.
<Text style={styles.detail}>{pdfText(event.details.join(' · '))}</Text>
) : null}
</View>
</View>
)
}
function Section({ title, note, events }: { title: string; note: string; events: BehandlingshistorikEvent[] }) {
return (
<View>
<Text style={styles.sectionHeading}>{title}</Text>
<Text style={styles.sectionNote}>{note}</Text>
{events.length === 0 ? (
<Text style={styles.empty}>Inga händelser i urvalet.</Text>
) : (
events.map((event) => <EventRow key={event.id} event={event} />)
)}
</View>
)
}
export interface BehandlingshistorikPDFProps {
report: BehandlingshistorikReport
}
export function BehandlingshistorikPDF({ report }: BehandlingshistorikPDFProps) {
const systemEvents = report.events.filter((e) => e.category !== 'verifikation')
const voucherEvents = report.events.filter((e) => e.category === 'verifikation')
const scope =
report.mode === 'fiscal_year'
? 'Hela räkenskapsåret'
: `${report.range.from} till ${report.range.to}`
const categoryFilter =
report.category_filter && report.category_filter.length > 0
? report.category_filter.map((c) => BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]).join(', ')
: null
const summary = BEHANDLINGSHISTORIK_CATEGORIES.filter((c) => report.by_category[c] > 0)
.map((c) => `${BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]} ${report.by_category[c]}`)
.join(' · ')
const generated = formatStockholmTimestamp(report.generated_at)
return (
<Document
title={`Behandlingshistorik ${report.period.name}`}
author={report.company.name}
subject="Behandlingshistorik enligt BFL 5 kap. 11 §"
>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>Behandlingshistorik</Text>
<Text style={styles.subtitle}>
{report.period.name} ({report.period.start} till {report.period.end}) · Urval: {scope}
{categoryFilter ? ` · Kategori: ${categoryFilter}` : ''}
</Text>
<Text style={styles.legal}>BFL 5 kap. 11 § · BFNAR 2013:2 punkt 9.16 · Tider i Europe/Stockholm</Text>
</View>
<View style={styles.companyInfo}>
{report.company.name ? <Text style={styles.companyName}>{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}>Antal händelser</Text>
<Text style={styles.metaValue}>{String(report.total_events)}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Källor</Text>
<Text style={styles.metaValue}>Verifikationer, oföränderlig ändringslogg, rättelselogg, importer</Text>
</View>
</View>
{summary ? <Text style={styles.summary}>{summary}</Text> : null}
<View style={styles.tableHeader} fixed>
<Text style={[styles.tableHeaderText, styles.hdrTime]}>Tidpunkt</Text>
<Text style={[styles.tableHeaderText, styles.hdrEvent]}>Händelse</Text>
<Text style={[styles.tableHeaderText, styles.hdrActor]}>Utförd av</Text>
<Text style={[styles.tableHeaderText, styles.hdrDetails]}>Detaljer</Text>
</View>
<Section
title="Ändringar i bokföringssystemet"
note="Kontoplan, inställningar som styr bokföringen, räkenskapsår, importer och åtkomst, med tidpunkt och utförare (BFNAR 2013:2 punkt 9.16 andra stycket)."
events={systemEvents}
/>
<Section
title="Bokföringsposter i registreringsordning"
note="Varje bokförd verifikation med registreringstidpunkt och utförare, samt makuleringar, rättelser och raderingar (BFNAR 2013:2 punkt 9.16 första stycket)."
events={voucherEvents}
/>
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{report.company.name}
{report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''}
{' · Behandlingshistorik'}
</Text>
<Text
style={styles.footerText}
render={({ pageNumber, totalPages }) => `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`}
/>
</View>
</Page>
</Document>
)
}
+2
View File
@@ -72,6 +72,8 @@ export interface BehandlingshistorikReport {
total_events: number
by_category: Record<BehandlingshistorikCategory, number>
events: BehandlingshistorikEvent[]
/** Category filter the report was generated with, if any (shown as "Urval" on the document). */
category_filter?: BehandlingshistorikCategory[] | null
}
/** Swedish category labels for exports and the statutory document. */
+10
View File
@@ -434,6 +434,15 @@ function fmtValue(value: unknown, key?: string): string {
if (value.length === 0) return '(tomt)'
return truncate(value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', '))
}
if (typeof value === 'object') {
// Settings maps such as default_voucher_series_per_source_type read better
// as "import: A, manual: A" than as raw JSON.
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return '(tomt)'
return truncate(
entries.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', '),
)
}
return truncate(JSON.stringify(value))
}
@@ -1513,6 +1522,7 @@ export async function generateBehandlingshistorik(
total_events: finalEvents.length,
by_category: byCategory,
events: finalEvents,
category_filter: params.categories && params.categories.length > 0 ? [...params.categories] : null,
}
}
+1 -1
View File
@@ -327,7 +327,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
descKey: 'desc_behandlingshistorik',
category: 'export',
params: 'fiscal-range',
exports: ['xlsx'],
exports: ['pdf', 'xlsx'],
libraryOnly: true,
searchTerms:
'behandlingshistorik audit trail audit log händelselogg ändringslogg logg historik vem gjorde vad processing history revision systemdokumentation',
+13
View File
@@ -19,6 +19,7 @@ import {
type TrialBalanceLike,
} from './archive-csv'
import { buildArchiveReadme, buildDriveFolderReadme } from './archive-readme'
import { currentAppVersion } from './app-version'
import type { GeneralLedgerReport } from './general-ledger'
import type {
AuditLogEntry,
@@ -1373,6 +1374,9 @@ async function buildSystemDoc(
name: branding.appName.toLowerCase(),
description: 'Bokforingssystem for enskild firma och aktiebolag',
url: branding.appUrl,
// BFNAR 2013:2 p. 9.16 second paragraph: program versions are system
// changes that affect processing; the archive names the running build.
version: currentAppVersion(),
},
kontoplan: {
standard: 'BAS 2026',
@@ -1408,6 +1412,15 @@ async function buildSystemDoc(
email: 'Resend',
export_format: 'SIE4',
},
// BFNAR 2013:2 p. 9.15: where and how the behandlingshistorik is produced.
behandlingshistorik: {
beskrivning:
'Skapas automatiskt (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.16): registreringstidpunkt och utförare för varje bokföringspost (journal_entries), förändringar via databasens oföränderliga ändringslogg audit_log (kontoplan, inställningar som styr bokföringen, räkenskapsår, API-nycklar, makuleringar, raderingar), rättelser i samma verifikat (journal_entry_rattelse_log) samt SIE-, bankfils- och migreringsloggar.',
rapport:
'Rapporter > Export & arkiv > Behandlingshistorik: per räkenskapsår eller datumintervall, som PDF, CSV eller Excel',
arkivfil: 'revision/behandlingshistorik.json i denna säkerhetsbackup (råa loggrader)',
tidszon: 'Europe/Stockholm i rapporten, UTC i JSON-filen',
},
generated_at: new Date().toISOString(),
fiscal_periods: periods.map((p) => ({
id: p.id,
+1 -1
View File
@@ -227,7 +227,7 @@ Momsperiod: [ ] Månad [ ] Kvartal [ ] Helår
9.2. Behandlingshistoriken genereras automatiskt av systemet och kan inte ändras av användaren.
9.3. Behandlingshistoriken exporteras under **Importera/Exportera > Exportera > Säkerhetsbackup**. Exporten är en ZIP-fil som innehåller `revision/behandlingshistorik.json` (alla ändringar) och `revision/systemdokumentation.json` (kontoplan, verifikationsserier, arkiveringsprinciper), utöver SIE-filer, rapporter och underlag.
9.3. Behandlingshistoriken tas fram under **Rapporter > Behandlingshistorik** per räkenskapsår eller datumintervall och kan laddas ner som PDF, CSV eller Excel. Rapporten visar registreringstidpunkt, utförare och detaljer för varje bokföringspost samt ändringar i bokföringssystemet (kontoplan, inställningar, räkenskapsår, importer, åtkomst) och anger programversionen. Behandlingshistoriken ingår även i säkerhetsbackupen under **Importera/Exportera > Exportera > Säkerhetsbackup**: ZIP-filen innehåller `revision/behandlingshistorik.json` (alla ändringar) och `revision/systemdokumentation.json` (kontoplan, verifikationsserier, arkiveringsprinciper, programversion), utöver SIE-filer, rapporter och underlag.
## 10. Import och export