diff --git a/app/api/reports/behandlingshistorik/__tests__/route.test.ts b/app/api/reports/behandlingshistorik/__tests__/route.test.ts index e22678ac..bd7c78dc 100644 --- a/app/api/reports/behandlingshistorik/__tests__/route.test.ts +++ b/app/api/reports/behandlingshistorik/__tests__/route.test.ts @@ -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() + }) }) diff --git a/app/api/reports/behandlingshistorik/route.ts b/app/api/reports/behandlingshistorik/route.ts index 971deb06..20567446 100644 --- a/app/api/reports/behandlingshistorik/route.ts +++ b/app/api/reports/behandlingshistorik/route.ts @@ -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: { diff --git a/components/reports/BehandlingshistorikView.tsx b/components/reports/BehandlingshistorikView.tsx index d9e44e3c..f0b938df 100644 --- a/components/reports/BehandlingshistorikView.tsx +++ b/components/reports/BehandlingshistorikView.tsx @@ -130,6 +130,7 @@ export function BehandlingshistorikView({ = { 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 = { diff --git a/lib/reports/__tests__/behandlingshistorik-pdf-template.test.ts b/lib/reports/__tests__/behandlingshistorik-pdf-template.test.ts new file mode 100644 index 00000000..606089ef --- /dev/null +++ b/lib/reports/__tests__/behandlingshistorik-pdf-template.test.ts @@ -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 { + 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 { + 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, + ) +}) diff --git a/lib/reports/app-version.ts b/lib/reports/app-version.ts new file mode 100644 index 00000000..a4b85353 --- /dev/null +++ b/lib/reports/app-version.ts @@ -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 +} diff --git a/lib/reports/behandlingshistorik-pdf-template.tsx b/lib/reports/behandlingshistorik-pdf-template.tsx new file mode 100644 index 00000000..c633a44e --- /dev/null +++ b/lib/reports/behandlingshistorik-pdf-template.tsx @@ -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 ( + + {formatStockholmTimestamp(event.occurred_at)} + + {pdfText(event.event)} + {event.object ? {pdfText(event.object)} : null} + + {pdfText(event.actor.label)} + + {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. + {pdfText(event.details.join(' · '))} + ) : null} + + + ) +} + +function Section({ title, note, events }: { title: string; note: string; events: BehandlingshistorikEvent[] }) { + return ( + + {title} + {note} + {events.length === 0 ? ( + Inga händelser i urvalet. + ) : ( + events.map((event) => ) + )} + + ) +} + +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 ( + + + + + Behandlingshistorik + + {report.period.name} ({report.period.start} till {report.period.end}) · Urval: {scope} + {categoryFilter ? ` · Kategori: ${categoryFilter}` : ''} + + BFL 5 kap. 11 § · BFNAR 2013:2 punkt 9.16 · Tider i Europe/Stockholm + + + {report.company.name ? {report.company.name} : null} + {report.company.org_number ? ( + Org.nr: {formatOrgNumber(report.company.org_number)} + ) : null} + + + + + + Genererad + {generated} + + + Programversion + {report.app_version ?? 'okänd'} + + + Antal händelser + {String(report.total_events)} + + + Källor + Verifikationer, oföränderlig ändringslogg, rättelselogg, importer + + + {summary ? {summary} : null} + + + Tidpunkt + Händelse + Utförd av + Detaljer + + +
+
+ + + + {report.company.name} + {report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''} + {' · Behandlingshistorik'} + + `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`} + /> + + + + ) +} diff --git a/lib/reports/behandlingshistorik-types.ts b/lib/reports/behandlingshistorik-types.ts index 4ce29771..f1c4b059 100644 --- a/lib/reports/behandlingshistorik-types.ts +++ b/lib/reports/behandlingshistorik-types.ts @@ -72,6 +72,8 @@ export interface BehandlingshistorikReport { total_events: number by_category: Record 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. */ diff --git a/lib/reports/behandlingshistorik.ts b/lib/reports/behandlingshistorik.ts index ce35d5ab..74250e0b 100644 --- a/lib/reports/behandlingshistorik.ts +++ b/lib/reports/behandlingshistorik.ts @@ -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) + 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, } } diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 26d16025..4598ac47 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -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', diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index c73ba27b..8a189629 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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, diff --git a/public/docs/systemdokumentation-mall.md b/public/docs/systemdokumentation-mall.md index 75494db0..3ed05bce 100644 --- a/public/docs/systemdokumentation-mall.md +++ b/public/docs/systemdokumentation-mall.md @@ -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