feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) (#1787)

* feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16)

Adds the per-räkenskapsår processing history as a first-class report in
Rapporter (Export & arkiv), with CSV/XLSX export. Until now the
behandlingshistorik only existed as raw audit_log JSON inside the
Säkerhetsbackup ZIP; revisorer ask for a readable per-year document.

- lib/reports/behandlingshistorik.ts: read model over journal_entries
  (committed_at = registreringsdatum, the complete source of bokföringsposter),
  the trigger-written audit_log (storno, deletions, diffs, kontoplan, settings,
  period lock/unlock/close, API keys, dimensions, accruals), the rättelse log,
  company_migration_resets, sie_imports and bank_file_imports. Field-level
  diffs with Swedish labels; company_settings restricted to processing-relevant
  keys (p. 9.16 second paragraph); kontoplan seeding and bulk underlag
  deletions collapse into one summary row; actor labels for users, API keys,
  MCP, agent, cron and system; fiscal-year mode unions audit rows touching the
  year's entries regardless of timestamp (bokslut/storno land after period_end),
  date-range mode narrows by registration time.
- GET /api/reports/behandlingshistorik?period_id&from_date&to_date&category&format
  (json|csv|xlsx), withRouteContext + Zod, e-mail labels via service-role
  profiles lookup scoped to the ids in the result, app version stamped.
- Report catalog row + focused view (category filter, export menu), sv/en.
- Tests: 30 read-model tests, 10 route tests; smoke-tested read-only on prod.

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

* fix(reports): keep behandlingshistorik queries statically resolvable for the schema guard

tests/schema/no-phantom-columns.test.ts counts `.or()` calls with non-literal
arguments as unresolvable and holds a ceiling (379); the report added two.
The audit_log table/action filter is now a string literal in the call (pinned
to AUDITED_TABLES / GLOBAL_ACTIONS by a unit test), and the migration-reset
lookup is two plain `.eq()` queries instead of an interpolated `.or()`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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:45:39 +02:00
committed by GitHub
parent d3409183c0
commit 4be51aae67
12 changed files with 2928 additions and 0 deletions
+1
View File
@@ -1150,3 +1150,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-21] RIP-4 step 4 = calibration. lib/agent/categorize/calibration.ts is the engine: isotonic regression via pool-adjacent-violators (distribution-free, monotonic) over (confidence, was_correct) samples → a calibrator that turns raw selector confidence into a probability that actually means what it says; plus reliabilityByBucket/ECE and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap (default 2000 kr) — so "säker" stays honest until proven. Measurement loop: migration 20260821100000 categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]); POST /api/agent/categorize/outcome logs one sample (proposed vs booked account → was_correct) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped to keep the corpus clean); AiCategorizeProposal surfaces the proposal metadata via onProposal. scripts/fit-categorize-calibration.ts (READ-ONLY) prints the reliability diagram + ECE + fitted calibrator once data exists — run it in a few weeks, then store the calibrator/thresholds where bandFor reads them and only THEN consider enabling auto-book. Fitting needs >=200 real samples so nothing calibrates today; the loop just starts collecting. Migration applies on merge (auto-apply-on-merge active) — not applied manually.
[2026-08-21] The categorize selector now reads the underlag, not just the bank line — the highest-leverage quality lever for the cold-start majority (prod: 365 companies with 32.7k unbooked tx, median 0 templates, so the LLM carries them). lib/agent/categorize/underlag.ts gathers the matched receipt/invoice text (receipts.matched_transaction_id + invoice_inbox_items.matched_transaction_id + the transaction's own document_attachments), rendered as bounded Swedish text (supplier, date, total, moms, line items). Core reads these tables directly via supabase (table names, not @/extensions imports — the tables live in the shared DB). POST /api/agent/categorize gathers it server-side when the caller didn't pass `underlag`, so the model sees the actual supplier + line items. Best-effort ('' on any failure); server-side only, no client change (so no conflict with the calibration PR #1784 which also touches the dialog). Prod read (project pwxtzglxptnnvjrpixpg) also confirmed: 3342 active counterparty templates / 26k occurrences → established users get strong instant candidates; NO backfill needed (templates already reflect historical bookings).
[2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math.
[2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing.
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
// withRouteContext handlers take (request, routeContext); this route has no dynamic params.
const routeCtx = createMockRouteParams({})
const call = (url: string) => GET(createMockRequest(url), routeCtx)
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const serviceFrom = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({ from: serviceFrom }),
}))
vi.mock('@/lib/reports/behandlingshistorik', () => ({
generateBehandlingshistorik: vi.fn(),
buildBehandlingshistorikExport: vi.fn(),
resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()),
}))
import {
generateBehandlingshistorik,
buildBehandlingshistorikExport,
resolveUserLabelsFromProfiles,
} from '@/lib/reports/behandlingshistorik'
import { GET } from '../route'
const mockGenerate = vi.mocked(generateBehandlingshistorik)
const mockExport = vi.mocked(buildBehandlingshistorikExport)
const mockResolve = vi.mocked(resolveUserLabelsFromProfiles)
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
const sampleReport = {
company: { name: 'Testbolaget AB', org_number: '556000-0001' },
period: { id: 'period-1', name: 'RÅ 2026', start: '2026-01-01', end: '2026-12-31' },
range: { from: '2026-01-01', to: '2026-12-31' },
mode: 'fiscal_year' as const,
generated_at: '2026-08-21T12:00:00.000Z',
app_version: 'abc1234',
total_events: 1,
by_category: { verifikation: 1, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 },
events: [
{
id: 'entry:1',
occurred_at: '2026-03-10T09:30:00.000Z',
category: 'verifikation' as const,
code: 'journal_entry.committed',
event: 'Verifikation bokförd',
object: 'A12',
actor: { type: 'user' as const, user_id: 'user-1', label: 'anna@example.se' },
details: ['Datum: 2026-03-09'],
source: 'journal_entries' as const,
count: 1,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
authed()
})
describe('GET /api/reports/behandlingshistorik', () => {
it('returns 401 when not authenticated', async () => {
unauthed()
const res = await call('/api/reports/behandlingshistorik?period_id=period-1')
expect(res.status).toBe(401)
expect(mockGenerate).not.toHaveBeenCalled()
})
it('returns 400 when period_id is missing', async () => {
const res = await call('/api/reports/behandlingshistorik')
expect(res.status).toBe(400)
expect(mockGenerate).not.toHaveBeenCalled()
})
it('returns 400 on an unknown format', async () => {
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
expect(res.status).toBe(400)
})
it('returns 404 when the period does not belong to the company', async () => {
mockGenerate.mockResolvedValue(null)
const res = await call('/api/reports/behandlingshistorik?period_id=nope')
expect(res.status).toBe(404)
})
it('returns 400 when the date sub-range falls outside the period', async () => {
enqueue({ data: { period_start: '2026-01-01', period_end: '2026-12-31' } })
const res = await call(
'/api/reports/behandlingshistorik?period_id=period-1&from_date=2025-06-01&to_date=2026-02-01',
)
expect(res.status).toBe(400)
expect(mockGenerate).not.toHaveBeenCalled()
})
it('returns 404 for a sub-range on an unknown period', async () => {
enqueue({ data: null })
const res = await call(
'/api/reports/behandlingshistorik?period_id=nope&from_date=2026-02-01',
)
expect(res.status).toBe(404)
})
it('returns the report as JSON and resolves actor labels through the service client', async () => {
mockGenerate.mockResolvedValue(sampleReport)
const res = await call(
'/api/reports/behandlingshistorik?period_id=period-1&category=verifikation',
)
expect(res.status).toBe(200)
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
const body = await res.json()
expect(body.data.total_events).toBe(1)
expect(body.data.events[0].object).toBe('A12')
expect(mockGenerate).toHaveBeenCalledTimes(1)
const [, companyId, params, options] = mockGenerate.mock.calls[0]
expect(companyId).toBe('company-1')
expect(params).toEqual({ periodId: 'period-1', fromDate: undefined, toDate: undefined, categories: ['verifikation'] })
// The injected resolver goes through resolveUserLabelsFromProfiles with the service client.
await options!.resolveUserLabels!(['user-1'])
expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ from: serviceFrom }), ['user-1'])
})
it('passes a validated sub-range through to the generator', async () => {
enqueue({ data: { period_start: '2026-01-01', period_end: '2026-12-31' } })
mockGenerate.mockResolvedValue({ ...sampleReport, mode: 'date_range', range: { from: '2026-03-01', to: '2026-03-31' } })
const res = await call(
'/api/reports/behandlingshistorik?period_id=period-1&from_date=2026-03-01&to_date=2026-03-31',
)
expect(res.status).toBe(200)
const [, , params] = mockGenerate.mock.calls[0]
expect(params).toMatchObject({ periodId: 'period-1', fromDate: '2026-03-01', toDate: '2026-03-31' })
})
it('streams CSV with an attachment filename', async () => {
mockGenerate.mockResolvedValue(sampleReport)
mockExport.mockReturnValue({
buffer: Buffer.from('Tidpunkt,Kategori\n', 'utf-8'),
contentType: 'text/csv; charset=utf-8',
filename: 'behandlingshistorik-testbolaget-ab-20261231.csv',
})
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=csv')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(res.headers.get('Content-Disposition')).toContain('attachment')
expect(res.headers.get('Content-Disposition')).toContain('behandlingshistorik-testbolaget-ab-20261231.csv')
expect(mockExport).toHaveBeenCalledWith(sampleReport, 'csv')
const text = await res.text()
expect(text).toContain('Tidpunkt,Kategori')
})
it('maps generator failures to the report error envelope', async () => {
mockGenerate.mockRejectedValue(new Error('relation audit_log does not exist'))
const res = await call('/api/reports/behandlingshistorik?period_id=period-1')
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
expect(JSON.stringify(body)).not.toContain('audit_log')
})
})
@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { BehandlingshistorikQuerySchema } from '@/lib/api/schemas'
import { contentDisposition } from '@/lib/api/content-disposition'
import { privateNoStore } from '@/lib/api/private-no-store'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
buildBehandlingshistorikExport,
generateBehandlingshistorik,
resolveUserLabelsFromProfiles,
} from '@/lib/reports/behandlingshistorik'
/**
* GET /api/reports/behandlingshistorik
*
* Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) for one fiscal
* period, optionally narrowed to a date sub-range inside it.
*
* Query: period_id (required), from_date / to_date (optional, inside the
* period), category (optional), format=json|csv|xlsx (default json).
*
* Read-only: the report is a view over journal_entries, the trigger-written
* audit_log, the rättelse log and the import tables. Actor e-mails are
* resolved through a service-role lookup on `profiles` (self-only RLS),
* 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
const query = validateQuery(request, BehandlingshistorikQuerySchema, {
log,
operation: 'report.behandlingshistorik',
})
if (!query.success) return query.response
const { period_id: periodId, from_date, to_date, format, category } = query.data
// Validate the optional sub-range against the period bounds (same contract
// as the other fiscal-range reports). Unknown period: 404.
if (from_date || to_date) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.maybeSingle()
if (!period) {
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
}
const { searchParams } = new URL(request.url)
const parsed = parseReportDateRange(searchParams, period as { period_start: string; period_end: string })
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
}
try {
const serviceClient = createServiceClient()
const report = await generateBehandlingshistorik(
supabase,
companyId,
{
periodId,
fromDate: from_date,
toDate: to_date,
categories: category ? [category] : undefined,
},
{
resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids),
appVersion: currentAppVersion(),
},
)
if (!report) {
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
}
if (format === 'json') {
return privateNoStore(NextResponse.json({ data: report }))
}
const file = buildBehandlingshistorikExport(report, format)
return new NextResponse(new Uint8Array(file.buffer), {
headers: {
'Content-Type': file.contentType,
'Content-Disposition': contentDisposition('attachment', file.filename),
'Cache-Control': 'private, no-store',
},
})
} catch (err) {
// Raw message stays server-side: it can carry table names / SQL.
log.error('behandlingshistorik generation failed', err as Error, { periodId })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId })
}
})
@@ -0,0 +1,187 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AlertCircle, History } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { formatDateTime } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { DateRangeValue } from '@/components/common/ReportDateRange'
import {
BEHANDLINGSHISTORIK_CATEGORIES,
type BehandlingshistorikCategory,
type BehandlingshistorikReport,
} from '@/lib/reports/behandlingshistorik-types'
type CategoryFilter = 'all' | BehandlingshistorikCategory
function buildQuery(periodId: string, dateRange: DateRangeValue): string {
const params = new URLSearchParams({ period_id: periodId })
if (dateRange.fromDate) params.set('from_date', dateRange.fromDate)
if (dateRange.toDate) params.set('to_date', dateRange.toDate)
return params.toString()
}
/**
* Behandlingshistorik report body (BFL 5 kap. 11 §). Event labels arrive in
* Swedish from the read model (räkenskapsinformation); the chrome here
* (headers, filter, counts) is translated.
*/
export function BehandlingshistorikView({
periodId,
dateRange,
}: {
periodId: string
dateRange: DateRangeValue
}) {
const t = useTranslations('reports')
const [data, setData] = useState<BehandlingshistorikReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<CategoryFilter>('all')
const query = buildQuery(periodId, dateRange)
useEffect(() => {
if (!periodId) return
let cancelled = false
const run = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/behandlingshistorik?${query}`)
const result = await res.json()
if (cancelled) return
if (!res.ok || result.error) {
setError(getErrorMessage(result))
} else {
setData(result.data)
}
} catch {
if (!cancelled) setError(t('bh_error'))
} finally {
if (!cancelled) setLoading(false)
}
}
run()
return () => {
cancelled = true
}
// t is stable for the mounted locale; the query string is the real input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [periodId, query])
const visible = useMemo(() => {
if (!data) return []
if (filter === 'all') return data.events
return data.events.filter((e) => e.category === filter)
}, [data, filter])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.events.length === 0) {
return <EmptyState icon={History} title={t('bh_empty_title')} description={t('bh_empty_desc')} />
}
const filterOptions = [
{ value: 'all' as CategoryFilter, label: t('bh_filter_all'), count: data.total_events },
...BEHANDLINGSHISTORIK_CATEGORIES.filter((c) => data.by_category[c] > 0).map((c) => ({
value: c as CategoryFilter,
label: t(`bh_cat_${c}`),
count: data.by_category[c],
})),
]
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="overflow-x-auto">
<SegmentedControl
value={filter}
onChange={setFilter}
options={filterOptions}
aria-label={t('bh_filter_aria')}
/>
</div>
<ReportExportMenu
items={[
{ format: 'xlsx', href: `/api/reports/behandlingshistorik?${query}&format=xlsx` },
{ format: 'csv', href: `/api/reports/behandlingshistorik?${query}&format=csv` },
]}
/>
</div>
<p className="text-sm text-muted-foreground tabular-nums">
{t('bh_summary', { count: data.total_events })} · {data.range.from} {t('bh_range_to')} {data.range.to}
{data.app_version ? ` · ${t('bh_version')}: ${data.app_version}` : ''}
</p>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[760px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="px-4 py-2 w-40">{t('bh_col_time')}</th>
<th className="px-4 py-2">{t('bh_col_event')}</th>
<th className="px-4 py-2 w-56">{t('bh_col_actor')}</th>
<th className="px-4 py-2">{t('bh_col_details')}</th>
</tr>
</thead>
<tbody>
{visible.map((e) => (
<tr key={e.id} className="border-b last:border-0 align-top">
<td className="px-4 py-2 tabular-nums whitespace-nowrap text-muted-foreground">
{formatDateTime(e.occurred_at)}
</td>
<td className="px-4 py-2">
<div>{e.event}</div>
{e.object && (
<div className="font-mono text-xs text-muted-foreground">{e.object}</div>
)}
</td>
<td className="px-4 py-2 text-muted-foreground break-words">{e.actor.label}</td>
<td className="px-4 py-2">
{e.details.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{e.details.map((d, i) => (
<li key={i}>{d}</li>
))}
</ul>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}
+6
View File
@@ -56,6 +56,10 @@ const INK2DeclarationView = dynamic(() =>
import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
{ loading: ReportViewLoading },
)
const BehandlingshistorikView = dynamic(() =>
import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })),
{ loading: ReportViewLoading },
)
const BankReconciliationView = dynamic(() =>
import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
{ loading: ReportViewLoading },
@@ -269,6 +273,8 @@ function FocusedView({
return <ARLedgerView periodId={periodId} />
case 'supplier-ledger':
return <SupplierLedgerView periodId={periodId} />
case 'behandlingshistorik':
return <BehandlingshistorikView periodId={periodId} dateRange={dateRange} />
case 'bank-reconciliation':
return (
<BankReconciliationView
+15
View File
@@ -2495,6 +2495,21 @@ export const AuditTrailQuerySchema = z.object({
page_size: z.coerce.number().int().min(1).max(200).default(50),
})
/**
* GET /api/reports/behandlingshistorik (BFL 5 kap. 11 §). period_id is the
* fiscal period; from_date/to_date narrow to a sub-range inside it (validated
* against the period bounds by the route, like the other fiscal-range reports).
*/
export const BehandlingshistorikQuerySchema = z.object({
period_id: z.string().min(1),
from_date: isoDate.optional(),
to_date: isoDate.optional(),
category: z
.enum(['verifikation', 'kontoplan', 'installningar', 'period', 'import', 'atkomst', 'ovrigt'])
.optional(),
format: z.enum(['json', 'csv', 'xlsx']).default('json'),
})
// ============================================================
// Voucher gap schemas
// ============================================================
@@ -0,0 +1,681 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import type { AuditLogEntry } from '@/types'
import {
AUDITED_TABLES,
AUDIT_ROW_FILTER,
GLOBAL_ACTIONS,
auditRowToEvent,
buildBehandlingshistorikExport,
collapseBursts,
commitEventFromEntry,
diffFields,
formatActorLabel,
formatStockholmTimestamp,
generateBehandlingshistorik,
rattelseEvent,
sortEvents,
type RawBehandlingshistorikEvent,
} from '../behandlingshistorik'
// ============================================================
// Fixtures
// ============================================================
let seq = 0
function auditRow(overrides: Partial<AuditLogEntry>): AuditLogEntry {
seq += 1
return {
id: `audit-${seq}`,
user_id: 'user-1',
company_id: 'company-1',
action: 'UPDATE',
table_name: 'journal_entries',
record_id: 'rec-1',
actor_id: 'user-1',
actor_type: 'user',
actor_label: null,
old_state: null,
new_state: null,
description: null,
created_at: '2026-03-10T10:00:00.000Z',
...overrides,
}
}
function rawEvent(overrides: Partial<RawBehandlingshistorikEvent>): RawBehandlingshistorikEvent {
seq += 1
return {
id: `ev-${seq}`,
occurred_at: '2026-03-10T10:00:00.000Z',
category: 'kontoplan',
code: 'account.created',
event: 'Konto tillagt',
object: '1930 Företagskonto',
actor: { type: 'user', user_id: 'user-1', actor_label: null },
details: [],
source: 'audit_log',
count: 1,
...overrides,
}
}
const baseEntry = {
id: 'entry-1',
voucher_series: 'A',
voucher_number: 12,
entry_date: '2026-03-09',
description: 'Hyra mars',
source_type: 'manual',
status: 'posted',
committed_at: '2026-03-10T09:30:00.000Z',
user_id: 'user-1',
committed_actor_type: null,
committed_actor_label: null,
commit_method: 'user_accept',
reverses_id: null,
correction_of_id: null,
}
// ============================================================
// audit_log row filter: the literal in the query must track the constants
// ============================================================
describe('AUDIT_ROW_FILTER', () => {
it('is built from AUDITED_TABLES and GLOBAL_ACTIONS', () => {
expect(AUDIT_ROW_FILTER).toBe(
`table_name.in.(${AUDITED_TABLES.join(',')}),action.in.(${GLOBAL_ACTIONS.join(',')})`,
)
})
it('is the exact literal used in the audit_log query (schema guard needs a literal there)', () => {
const source = readFileSync(path.join(__dirname, '..', 'behandlingshistorik.ts'), 'utf-8')
expect(source).toContain(`.or(\n '${AUDIT_ROW_FILTER}',\n )`)
})
})
// ============================================================
// diffFields
// ============================================================
describe('diffFields', () => {
it('reports only allow-listed keys that changed, in allow-list order', () => {
const { lines, keys } = diffFields(
{ a: 1, b: 'x', c: true, noise: 1 },
{ a: 1, b: 'y', c: false, noise: 2 },
{ c: 'C-etikett', b: 'B-etikett' },
)
expect(keys).toEqual(['c', 'b'])
expect(lines).toEqual(['C-etikett: Ja → Nej', 'B-etikett: x → y'])
})
it('renders null/empty as (tomt) and maps known setting values to Swedish', () => {
const { lines } = diffFields(
{ accounting_method: null },
{ accounting_method: 'cash' },
{ accounting_method: 'Redovisningsmetod' },
)
expect(lines).toEqual(['Redovisningsmetod: (tomt) → Kontantmetoden'])
})
})
// ============================================================
// commitEventFromEntry
// ============================================================
describe('commitEventFromEntry', () => {
it('turns a posted entry into a bokförd event with registreringsdatum = committed_at', () => {
const ev = commitEventFromEntry(baseEntry)!
expect(ev).toMatchObject({
id: 'entry:entry-1',
occurred_at: '2026-03-10T09:30:00.000Z',
category: 'verifikation',
code: 'journal_entry.committed',
event: 'Verifikation bokförd',
object: 'A12',
source: 'journal_entries',
actor: { type: 'user', user_id: 'user-1' },
})
expect(ev.details).toEqual([
'Datum: 2026-03-09',
'Text: Hyra mars',
'Källa: Manuell',
'Bokföringssätt: Godkänd av användare',
])
})
it('skips drafts and cancelled entries', () => {
expect(commitEventFromEntry({ ...baseEntry, status: 'draft', committed_at: null })).toBeNull()
expect(commitEventFromEntry({ ...baseEntry, status: 'cancelled' })).toBeNull()
})
it('carries the machine actor from committed_actor_type / label', () => {
const ev = commitEventFromEntry({
...baseEntry,
committed_actor_type: 'api_key',
committed_actor_label: 'Zapier',
commit_method: 'api_key',
})!
expect(ev.actor).toEqual({ type: 'api_key', user_id: 'user-1', actor_label: 'Zapier' })
})
it('marks storno and rättelse vouchers', () => {
const storno = commitEventFromEntry({ ...baseEntry, reverses_id: 'x', source_type: 'storno' })!
expect(storno.details).toContain('Vändningsverifikation (storno)')
const corr = commitEventFromEntry({ ...baseEntry, correction_of_id: 'x', source_type: 'correction' })!
expect(corr.details).toContain('Rättelseverifikation')
})
})
// ============================================================
// auditRowToEvent
// ============================================================
describe('auditRowToEvent: journal_entries', () => {
it('ignores COMMIT rows (the bokföringspost comes from journal_entries)', () => {
expect(auditRowToEvent(auditRow({ action: 'COMMIT', new_state: { status: 'posted' } }))).toBeNull()
})
it('emits REVERSE as makulerad with the voucher label', () => {
const ev = auditRowToEvent(
auditRow({ action: 'REVERSE', old_state: { voucher_series: 'A', voucher_number: 5, status: 'posted' }, new_state: { voucher_series: 'A', voucher_number: 5, status: 'reversed' } }),
)!
expect(ev).toMatchObject({ code: 'journal_entry.reversed', object: 'A5', category: 'verifikation' })
})
it('emits DELETE only for booked entries', () => {
expect(auditRowToEvent(auditRow({ action: 'DELETE', old_state: { status: 'draft', voucher_series: 'A', voucher_number: null } }))).toBeNull()
const ev = auditRowToEvent(
auditRow({ action: 'DELETE', old_state: { status: 'posted', voucher_series: 'A', voucher_number: 7, entry_date: '2026-01-02', description: 'Fel' } }),
)!
expect(ev).toMatchObject({ code: 'journal_entry.deleted', object: 'A7' })
expect(ev.details).toEqual(['Datum: 2026-01-02', 'Text: Fel'])
})
it('emits UPDATE diffs on booked entries and skips draft edits / no-op updates', () => {
const draft = auditRow({ action: 'UPDATE', old_state: { status: 'draft', description: 'a' }, new_state: { status: 'draft', description: 'b' } })
expect(auditRowToEvent(draft)).toBeNull()
const noop = auditRow({ action: 'UPDATE', old_state: { status: 'posted', updated_at: '1' }, new_state: { status: 'posted', updated_at: '2' } })
expect(auditRowToEvent(noop)).toBeNull()
const ev = auditRowToEvent(
auditRow({
action: 'UPDATE',
old_state: { status: 'posted', voucher_series: 'A', voucher_number: 3, notes: null },
new_state: { status: 'posted', voucher_series: 'A', voucher_number: 3, notes: 'Kvitto saknas' },
}),
)!
expect(ev).toMatchObject({ code: 'journal_entry.updated', object: 'A3' })
expect(ev.details).toEqual(['Notering: (tomt) → Kvitto saknas'])
})
it('suppresses the trigger UPDATE row that duplicates a metadata rättelse', () => {
const row = auditRow({
action: 'UPDATE',
record_id: 'entry-9',
created_at: '2026-03-10T10:00:05.000Z',
old_state: { status: 'posted', description: 'a', voucher_series: 'A', voucher_number: 9 },
new_state: { status: 'posted', description: 'b', voucher_series: 'A', voucher_number: 9 },
})
const ctx = {
rattelseMetadataAt: new Map([['entry-9', [Date.parse('2026-03-10T10:00:00.000Z')]]]),
entryById: new Map(),
}
expect(auditRowToEvent(row, ctx)).toBeNull()
// A different entry, or a change that is not just description/date, is kept.
expect(auditRowToEvent({ ...row, record_id: 'entry-8' }, ctx)).not.toBeNull()
})
it('emits COMMITTED_AT_OVERRIDE with preset vs wall clock', () => {
const ev = auditRowToEvent(
auditRow({
action: 'COMMITTED_AT_OVERRIDE',
actor_type: 'system',
new_state: { preset_committed_at: '2025-01-01T00:00:00Z', wall_clock: '2026-08-17T10:00:00Z', jwt_role: 'service_role' },
}),
)!
expect(ev.code).toBe('journal_entry.committed_at_override')
expect(ev.actor.type).toBe('system')
expect(ev.details[0]).toContain('2025-01-01')
})
})
describe('auditRowToEvent: system changes', () => {
it('kontoplan: INSERT / UPDATE diff / DELETE, and no-op UPDATE is dropped', () => {
const ins = auditRowToEvent(
auditRow({ table_name: 'chart_of_accounts', action: 'INSERT', new_state: { account_number: '6540', account_name: 'IT-tjänster', account_type: 'expense', default_vat_code: '25' } }),
)!
expect(ins).toMatchObject({ category: 'kontoplan', code: 'account.created', object: '6540 IT-tjänster' })
expect(ins.details).toEqual(['Typ: expense', 'Momskod: 25'])
const upd = auditRowToEvent(
auditRow({
table_name: 'chart_of_accounts',
action: 'UPDATE',
old_state: { account_number: '6540', account_name: 'IT-tjänster', default_vat_code: '25', updated_at: 'x' },
new_state: { account_number: '6540', account_name: 'Programvaror', default_vat_code: '25', updated_at: 'y' },
}),
)!
expect(upd.details).toEqual(['Namn: IT-tjänster → Programvaror'])
const noop = auditRowToEvent(
auditRow({ table_name: 'chart_of_accounts', action: 'UPDATE', old_state: { account_number: '6540', sort_order: 1 }, new_state: { account_number: '6540', sort_order: 2 } }),
)
expect(noop).toBeNull()
const del = auditRowToEvent(auditRow({ table_name: 'chart_of_accounts', action: 'DELETE', old_state: { account_number: '6540', account_name: 'X' } }))!
expect(del.code).toBe('account.deleted')
})
it('company_settings: only processing-relevant keys produce an event', () => {
const counter = auditRowToEvent(
auditRow({ table_name: 'company_settings', action: 'UPDATE', old_state: { next_invoice_number: 10 }, new_state: { next_invoice_number: 11 } }),
)
expect(counter).toBeNull()
const ev = auditRowToEvent(
auditRow({
table_name: 'company_settings',
action: 'UPDATE',
old_state: { moms_period: 'quarterly', accounting_method: 'invoice', invoice_footer_text: 'a' },
new_state: { moms_period: 'yearly', accounting_method: 'invoice', invoice_footer_text: 'b' },
}),
)!
expect(ev).toMatchObject({ category: 'installningar', code: 'settings.updated' })
expect(ev.details).toEqual(['Momsperiod: Kvartal → Helår'])
})
it('fiscal_periods: lock, close, app-written unlock, closed externally', () => {
const lock = auditRowToEvent(auditRow({ table_name: 'fiscal_periods', action: 'LOCK_PERIOD', new_state: { name: 'RÅ 2025' } }))!
expect(lock).toMatchObject({ category: 'period', code: 'period.locked', object: 'RÅ 2025' })
const close = auditRowToEvent(auditRow({ table_name: 'fiscal_periods', action: 'CLOSE_PERIOD', new_state: { name: 'RÅ 2025' } }))!
expect(close.code).toBe('period.closed')
const unlock = auditRowToEvent(
auditRow({
table_name: 'fiscal_periods',
action: 'UPDATE',
old_state: { locked_at: '2026-01-01T00:00:00Z' },
new_state: { locked_at: null },
description: 'Period unlocked: RÅ 2025 (2025-01-01 to 2025-12-31)',
}),
)!
expect(unlock).toMatchObject({ code: 'period.unlocked', object: 'RÅ 2025 (2025-01-01 to 2025-12-31)' })
const ext = auditRowToEvent(
auditRow({
table_name: 'fiscal_periods',
action: 'UPDATE',
old_state: { is_closed: false, closed_at: null, locked_at: null },
new_state: { is_closed: true, closed_at: 'x', closed_externally: true, locked_at: 'y' },
}),
)!
expect(ext.code).toBe('period.closed_externally')
})
it('api_keys: created with scopes, revoked, and usage-only updates dropped', () => {
const created = auditRowToEvent(
auditRow({ table_name: 'api_keys', action: 'INSERT', new_state: { name: 'Zapier', scopes: ['read', 'write'] } }),
)!
expect(created).toMatchObject({ category: 'atkomst', code: 'api_key.created', object: 'Zapier' })
expect(created.details).toEqual(['Behörigheter: read, write'])
const revoked = auditRowToEvent(
auditRow({ table_name: 'api_keys', action: 'UPDATE', old_state: { name: 'Zapier', revoked_at: null }, new_state: { name: 'Zapier', revoked_at: 'now' } }),
)!
expect(revoked.code).toBe('api_key.revoked')
const usage = auditRowToEvent(
auditRow({ table_name: 'api_keys', action: 'UPDATE', old_state: { name: 'Zapier', request_count: 1 }, new_state: { name: 'Zapier', request_count: 2 } }),
)
expect(usage).toBeNull()
})
it('global actions land in ovrigt regardless of table; registers are ignored', () => {
const sec = auditRowToEvent(
auditRow({ table_name: 'webhooks', action: 'SECURITY_EVENT', actor_type: 'system', description: 'Signature mismatch' }),
)!
expect(sec).toMatchObject({ category: 'ovrigt', code: 'security.event', details: ['Signature mismatch'] })
expect(auditRowToEvent(auditRow({ table_name: 'supplier_invoices', action: 'INSERT', new_state: { id: 'x' } }))).toBeNull()
expect(auditRowToEvent(auditRow({ table_name: 'document_attachments', action: 'INSERT', new_state: { file_name: 'a.pdf' } }))).toBeNull()
expect(auditRowToEvent(auditRow({ table_name: 'document_attachments', action: 'DELETE', old_state: { file_name: 'a.pdf' } }))!.code).toBe('document.deleted')
})
})
// ============================================================
// rattelseEvent
// ============================================================
describe('rattelseEvent', () => {
it('describes struck and added lines with account and amount', () => {
const ev = rattelseEvent(
{
id: 'r1',
journal_entry_id: 'entry-1',
rattelse_type: 'lines',
old_description: null,
new_description: null,
old_entry_date: null,
new_entry_date: null,
struck_lines: [{ account_number: '6540', debit_amount: 1200, credit_amount: 0 }],
added_lines: [{ account_number: '6550', debit_amount: 1200, credit_amount: 0 }],
actor: 'user-2',
created_at: '2026-03-11T08:00:00Z',
},
new Map([['entry-1', baseEntry]]),
)
expect(ev).toMatchObject({ code: 'journal_entry.corrected_lines', object: 'A12', actor: { user_id: 'user-2' } })
expect(ev.details[0]).toContain('6540 D 1')
expect(ev.details[1]).toContain('6550 D 1')
})
it('describes metadata changes', () => {
const ev = rattelseEvent(
{
id: 'r2',
journal_entry_id: 'missing',
rattelse_type: 'metadata',
old_description: 'Hyra',
new_description: 'Hyra mars',
old_entry_date: '2026-03-01',
new_entry_date: '2026-03-01',
struck_lines: null,
added_lines: null,
actor: 'user-2',
created_at: '2026-03-11T08:00:00Z',
},
new Map(),
)
expect(ev.object).toBeNull()
expect(ev.details).toEqual(['Beskrivning: Hyra → Hyra mars'])
})
})
// ============================================================
// collapse + sort + labels
// ============================================================
describe('collapseBursts', () => {
it('collapses a run of same-actor account inserts into one event and keeps short runs', () => {
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
const burst = Array.from({ length: 12 }, (_, i) =>
rawEvent({ occurred_at: new Date(t0 + i * 1000).toISOString(), object: `${1000 + i} Konto ${i}` }),
)
const other = rawEvent({
code: 'settings.updated',
category: 'installningar',
event: 'Företagsinställningar ändrade',
occurred_at: new Date(t0 + 20_000).toISOString(),
object: null,
})
const small = Array.from({ length: 3 }, (_, i) =>
rawEvent({ occurred_at: new Date(t0 + 30_000 + i * 1000).toISOString(), object: `20${i}0 Konto` }),
)
const out = collapseBursts(sortEvents([...burst, other, ...small]))
expect(out).toHaveLength(1 + 1 + 3)
expect(out[0]).toMatchObject({ code: 'account.created.bulk', event: 'Kontoplan upplagd', object: '12 konton', count: 12 })
expect(out[0].details).toEqual(['Konton 1000 till 1011'])
expect(out[1].code).toBe('settings.updated')
})
it('splits runs on actor change and on a gap', () => {
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
const a = Array.from({ length: 10 }, (_, i) => rawEvent({ occurred_at: new Date(t0 + i * 1000).toISOString() }))
const b = Array.from({ length: 10 }, (_, i) =>
rawEvent({ occurred_at: new Date(t0 + 10_000 + i * 1000).toISOString(), actor: { type: 'api_key', user_id: null, actor_label: 'Sync' } }),
)
const c = Array.from({ length: 10 }, (_, i) => rawEvent({ occurred_at: new Date(t0 + 600_000 + i * 1000).toISOString() }))
const out = collapseBursts(sortEvents([...a, ...b, ...c]))
expect(out.map((e) => e.count)).toEqual([10, 10, 10])
})
it('collapses bulk underlag deletions and lists the first file names', () => {
const t0 = Date.parse('2026-08-10T12:40:45.000Z')
const run = Array.from({ length: 7 }, (_, i) =>
rawEvent({
code: 'document.deleted',
category: 'ovrigt',
event: 'Underlag borttaget',
occurred_at: new Date(t0 + i * 100).toISOString(),
object: `Receipt-${Math.floor(i / 2)}.pdf`,
}),
)
const two = run.slice(0, 2)
expect(collapseBursts(two)).toHaveLength(2)
const out = collapseBursts(run)
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({ code: 'document.deleted.bulk', event: 'Underlag borttagna', object: '7 underlag', category: 'ovrigt', count: 7 })
expect(out[0].details).toEqual(['Receipt-0.pdf, Receipt-1.pdf, Receipt-2.pdf, Receipt-3.pdf'])
})
it('summarises which fields a bulk update touched', () => {
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
const run = Array.from({ length: 10 }, (_, i) =>
rawEvent({
code: 'account.updated',
event: 'Konto ändrat',
occurred_at: new Date(t0 + i).toISOString(),
details: [i % 2 ? 'Momskod: 25 → 12' : 'Namn: a → b'],
}),
)
const out = collapseBursts(run)
expect(out[0].details).toContain('Ändrade fält: Namn, Momskod')
})
})
describe('formatActorLabel', () => {
const labels = new Map([['user-1', 'anna@example.se']])
it('maps every actor type', () => {
expect(formatActorLabel({ type: 'user', user_id: 'user-1', actor_label: null }, labels)).toBe('anna@example.se')
expect(formatActorLabel({ type: 'user', user_id: 'deadbeef-0000', actor_label: null }, labels)).toBe('Användare deadbeef')
expect(formatActorLabel({ type: 'user', user_id: null, actor_label: null }, labels)).toBe('Okänd användare')
expect(formatActorLabel({ type: 'api_key', user_id: 'user-1', actor_label: 'Zapier' }, labels)).toBe('API-nyckel: Zapier')
expect(formatActorLabel({ type: 'mcp_oauth', user_id: null, actor_label: null }, labels)).toBe('MCP-anslutning')
expect(formatActorLabel({ type: 'agent_chat', user_id: 'user-1', actor_label: null }, labels)).toBe('Assistenten, på uppdrag av anna@example.se')
expect(formatActorLabel({ type: 'cron', user_id: null, actor_label: null }, labels)).toBe('Schemalagd körning')
expect(formatActorLabel({ type: 'system', user_id: null, actor_label: 'seed' }, labels)).toBe('Systemet: seed')
})
})
describe('formatStockholmTimestamp', () => {
it('renders Swedish local time', () => {
expect(formatStockholmTimestamp('2026-03-10T10:00:00.000Z')).toBe('2026-03-10 11:00:00')
expect(formatStockholmTimestamp('2026-07-10T10:00:00.000Z')).toBe('2026-07-10 12:00:00')
expect(formatStockholmTimestamp('not a date')).toBe('not a date')
})
})
// ============================================================
// generateBehandlingshistorik (table-keyed mock client)
// ============================================================
type MockResult = { data?: unknown; error?: unknown }
let mockResults: Record<string, MockResult[]>
function makeBuilder(table: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'or', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
const queue = mockResults[table]
if (!queue || queue.length === 0) return { data: null, error: null }
return queue.shift()!
}
b.maybeSingle = vi.fn().mockImplementation(async () => consume())
b.single = vi.fn().mockImplementation(async () => consume())
b.then = (resolve: (v: unknown) => void) => resolve(consume())
return b
}
function makeClient() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { from: vi.fn().mockImplementation((table: string) => makeBuilder(table)) } as any
}
const period = { id: 'period-1', name: 'RÅ 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
beforeEach(() => {
mockResults = {}
})
describe('generateBehandlingshistorik', () => {
it('returns null when the period does not belong to the company', async () => {
mockResults = { fiscal_periods: [{ data: null }] }
const report = await generateBehandlingshistorik(makeClient(), 'company-1', { periodId: 'nope' })
expect(report).toBeNull()
})
it('assembles, sorts and labels events from every source in fiscal-year mode', async () => {
const bokslut = { ...baseEntry, id: 'entry-2', voucher_number: 40, entry_date: '2026-12-31', committed_at: '2027-02-15T12:00:00.000Z', source_type: 'year_end', description: 'Bokslut' }
mockResults = {
fiscal_periods: [{ data: period }],
company_settings: [{ data: { company_name: 'Testbolaget AB', org_number: '556000-0001' } }],
journal_entries: [{ data: [baseEntry, bokslut, { ...baseEntry, id: 'draft', status: 'draft', committed_at: null, voucher_number: null }] }],
audit_log: [
// windowed
{
data: [
auditRow({ id: 'a1', table_name: 'company_settings', action: 'UPDATE', created_at: '2026-02-01T08:00:00.000Z', old_state: { moms_period: 'quarterly' }, new_state: { moms_period: 'monthly' } }),
auditRow({ id: 'a2', table_name: 'chart_of_accounts', action: 'INSERT', created_at: '2026-02-02T08:00:00.000Z', new_state: { account_number: '6540', account_name: 'IT' } }),
],
},
// record-id union (bokslut storno logged after period end)
{
data: [
auditRow({ id: 'a3', record_id: 'entry-2', action: 'REVERSE', created_at: '2027-03-01T09:00:00.000Z', old_state: { voucher_series: 'A', voucher_number: 40, status: 'posted' }, new_state: { voucher_series: 'A', voucher_number: 40, status: 'reversed' }, actor_type: 'api_key', actor_label: 'Revisorn' }),
],
},
],
journal_entry_rattelse_log: [{ data: [] }, { data: [] }],
company_migration_resets: [{ data: [] }, { data: [] }],
sie_imports: [
{
data: [
{ id: 's1', user_id: 'user-3', filename: 'bokio.se', sie_type: 4, fiscal_year_start: '2025-01-01', fiscal_year_end: '2025-12-31', accounts_count: 120, transactions_count: 900, status: 'completed', error_message: null, imported_at: '2026-01-05T10:00:00.000Z', created_at: '2026-01-05T09:55:00.000Z', replaced_at: null },
{ id: 's0', user_id: 'user-3', filename: 'old.se', sie_type: 4, fiscal_year_start: null, fiscal_year_end: null, accounts_count: null, transactions_count: null, status: 'completed', error_message: null, imported_at: '2025-06-01T10:00:00.000Z', created_at: '2025-06-01T10:00:00.000Z', replaced_at: null },
],
},
],
bank_file_imports: [
{ data: [{ id: 'b1', user_id: 'user-1', filename: 'seb.csv', file_format: 'seb', transaction_count: 40, imported_count: 38, duplicate_count: 2, status: 'completed', error_message: null, date_from: '2026-01-01', date_to: '2026-01-31', created_at: '2026-02-03T08:00:00.000Z' }] },
],
}
const resolve = vi.fn().mockResolvedValue(new Map([['user-1', 'anna@example.se'], ['user-3', 'kim@example.se']]))
const report = await generateBehandlingshistorik(makeClient(), 'company-1', { periodId: 'period-1' }, {
resolveUserLabels: resolve,
appVersion: 'abc1234',
now: new Date('2026-08-21T12:00:00.000Z'),
})
expect(report).not.toBeNull()
expect(report!.mode).toBe('fiscal_year')
expect(report!.company).toEqual({ name: 'Testbolaget AB', org_number: '556000-0001' })
expect(report!.app_version).toBe('abc1234')
expect(report!.range).toEqual({ from: '2026-01-01', to: '2026-12-31' })
// Both booked entries (bokslut entry committed after period end included), no draft.
const codes = report!.events.map((e) => e.code)
expect(codes).toEqual([
'sie_import.completed',
'settings.updated',
'account.created',
'bank_file_import.completed',
'journal_entry.committed',
'journal_entry.committed',
'journal_entry.reversed',
])
expect(report!.total_events).toBe(7)
expect(report!.by_category).toMatchObject({ verifikation: 3, kontoplan: 1, installningar: 1, import: 2 })
// Labels resolved through the injected resolver, machine actors kept.
expect(resolve).toHaveBeenCalledWith(expect.arrayContaining(['user-1', 'user-3']))
const byCode = Object.fromEntries(report!.events.map((e) => [e.id, e]))
expect(byCode['entry:entry-1'].actor.label).toBe('anna@example.se')
expect(byCode['sie:s1'].actor.label).toBe('kim@example.se')
expect(byCode['audit:a3'].actor.label).toBe('API-nyckel: Revisorn')
// The sie import outside the window is not included.
expect(byCode['sie:s0']).toBeUndefined()
})
it('date-range mode keeps only what was registered inside the window and filters categories', async () => {
mockResults = {
fiscal_periods: [{ data: period }],
company_settings: [{ data: { company_name: 'T', org_number: null } }],
journal_entries: [{ data: [baseEntry, { ...baseEntry, id: 'entry-2', voucher_number: 13, committed_at: '2026-05-02T10:00:00.000Z' }] }],
audit_log: [
{ data: [auditRow({ id: 'a1', table_name: 'chart_of_accounts', action: 'DELETE', created_at: '2026-03-15T08:00:00.000Z', old_state: { account_number: '6540', account_name: 'IT' } })] },
],
journal_entry_rattelse_log: [{ data: [] }],
company_migration_resets: [{ data: [] }, { data: [] }],
sie_imports: [{ data: [] }],
bank_file_imports: [{ data: [] }],
}
const client = makeClient()
const report = await generateBehandlingshistorik(client, 'company-1', {
periodId: 'period-1',
fromDate: '2026-03-01',
toDate: '2026-03-31',
categories: ['verifikation'],
})
expect(report!.mode).toBe('date_range')
expect(report!.events.map((e) => e.id)).toEqual(['entry:entry-1'])
expect(report!.by_category.kontoplan).toBe(0)
// No record-id union in date-range mode: audit_log queried once.
expect(client.from.mock.calls.filter((c: string[]) => c[0] === 'audit_log')).toHaveLength(1)
})
})
// ============================================================
// export
// ============================================================
describe('buildBehandlingshistorikExport', () => {
const report = {
company: { name: 'Testbolaget AB', org_number: '556000-0001' },
period: { id: 'p', name: 'RÅ 2026', start: '2026-01-01', end: '2026-12-31' },
range: { from: '2026-01-01', to: '2026-12-31' },
mode: 'fiscal_year' as const,
generated_at: '2026-08-21T12:00:00.000Z',
app_version: 'abc1234',
total_events: 1,
by_category: { verifikation: 1, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 },
events: [
{
id: 'entry:1',
occurred_at: '2026-03-10T09:30:00.000Z',
category: 'verifikation' as const,
code: 'journal_entry.committed',
event: 'Verifikation bokförd',
object: 'A12',
actor: { type: 'user' as const, user_id: 'u', label: 'anna@example.se' },
details: ['Datum: 2026-03-09', 'Källa: Manuell'],
source: 'journal_entries' as const,
count: 1,
},
],
}
it('csv carries a BOM, the header row and Swedish local time', () => {
const out = buildBehandlingshistorikExport(report, 'csv')
expect(out.contentType).toBe('text/csv; charset=utf-8')
expect(out.filename).toBe('behandlingshistorik-testbolaget-ab-20261231.csv')
const text = out.buffer.toString('utf-8')
expect(text.charCodeAt(0)).toBe(0xfeff)
// Exactly one BOM: SheetJS adds its own for csv, we must not double it.
expect(text.charCodeAt(1)).not.toBe(0xfeff)
expect(text.startsWith('Tidpunkt,Kategori,Händelse,Objekt,Utförd av,Detaljer,Kod,Antal')).toBe(true)
expect(text).toContain('2026-03-10 10:30:00')
expect(text).toContain('Datum: 2026-03-09 | Källa: Manuell')
})
it('xlsx is a non-empty workbook with the xlsx mime type', () => {
const out = buildBehandlingshistorikExport(report, 'xlsx')
expect(out.contentType).toContain('spreadsheetml')
expect(out.filename.endsWith('.xlsx')).toBe(true)
expect(out.buffer.length).toBeGreaterThan(100)
})
})
+86
View File
@@ -0,0 +1,86 @@
/**
* Behandlingshistorik: shared types and constants.
*
* Kept separate from the generator so client components can import the
* shapes without pulling the xlsx builder into the browser bundle.
*/
export const BEHANDLINGSHISTORIK_CATEGORIES = [
'verifikation',
'kontoplan',
'installningar',
'period',
'import',
'atkomst',
'ovrigt',
] as const
export type BehandlingshistorikCategory = (typeof BEHANDLINGSHISTORIK_CATEGORIES)[number]
export type BehandlingshistorikActorType =
| 'user'
| 'api_key'
| 'mcp_oauth'
| 'cron'
| 'agent_chat'
| 'system'
export type BehandlingshistorikSource =
| 'journal_entries'
| 'audit_log'
| 'rattelse_log'
| 'migration_reset'
| 'sie_import'
| 'bank_file_import'
export interface BehandlingshistorikActor {
type: BehandlingshistorikActorType
user_id: string | null
/** Human-readable: e-mail for users, key name for API keys, "Systemet", ... */
label: string
}
export interface BehandlingshistorikEvent {
/** Stable per source row, e.g. `audit:<uuid>`, `entry:<uuid>`. */
id: string
/** Registreringstidpunkt, ISO 8601 UTC. */
occurred_at: string
category: BehandlingshistorikCategory
/** Stable machine code, e.g. `journal_entry.committed`. */
code: string
/** Swedish event label (räkenskapsinformation: stays Swedish in both locales). */
event: string
/** What the event concerns: voucher label, account, period name, file name. */
object: string | null
actor: BehandlingshistorikActor
/** Human-readable detail lines (field diffs, counts, reasons). */
details: string[]
source: BehandlingshistorikSource
/** Number of underlying rows this event summarises (burst collapse). */
count: number
}
export interface BehandlingshistorikReport {
company: { name: string; org_number: string | null }
period: { id: string; name: string; start: string; end: string }
/** Effective window (ISO dates, inclusive). */
range: { from: string; to: string }
mode: 'fiscal_year' | 'date_range'
generated_at: string
/** Running software version at generation time (BFNAR 2013:2 p. 9.16 second paragraph). */
app_version: string | null
total_events: number
by_category: Record<BehandlingshistorikCategory, number>
events: BehandlingshistorikEvent[]
}
/** Swedish category labels for exports and the statutory document. */
export const BEHANDLINGSHISTORIK_CATEGORY_LABELS: Record<BehandlingshistorikCategory, string> = {
verifikation: 'Verifikationer',
kontoplan: 'Kontoplan',
installningar: 'Inställningar',
period: 'Räkenskapsår',
import: 'Import',
atkomst: 'Åtkomst',
ovrigt: 'Övrigt',
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -317,6 +317,21 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
route: '/import?view=export#sie-export',
libraryOnly: true,
},
{
// Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16): the
// per-räkenskapsår processing history revisorer ask for at bokslut. Lives
// with export & arkiv like Visma's Bokföring > Rapporter placement; the
// date sub-range narrows to "what happened between these dates".
slug: 'behandlingshistorik',
labelKey: 'name_behandlingshistorik',
descKey: 'desc_behandlingshistorik',
category: 'export',
params: 'fiscal-range',
exports: ['xlsx'],
libraryOnly: true,
searchTerms:
'behandlingshistorik audit trail audit log händelselogg ändringslogg logg historik vem gjorde vad processing history revision systemdokumentation',
},
]
/** Reports that take a fiscal period + optional date sub-range. */
+21
View File
@@ -6622,6 +6622,27 @@
"name_ink2_declaration": "INK2",
"name_huvudbok": "General ledger",
"name_grundbok": "Journal register",
"name_behandlingshistorik": "Processing history",
"desc_behandlingshistorik": "Who did what and when: posted vouchers, corrections and changes to the system (BFL 5 kap. 11 §)",
"bh_summary": "{count, plural, =1 {1 event} other {# events}}",
"bh_range_to": "to",
"bh_version": "Software version",
"bh_col_time": "Time",
"bh_col_event": "Event",
"bh_col_actor": "Performed by",
"bh_col_details": "Details",
"bh_filter_all": "All",
"bh_filter_aria": "Filter events by category",
"bh_cat_verifikation": "Vouchers",
"bh_cat_kontoplan": "Chart of accounts",
"bh_cat_installningar": "Settings",
"bh_cat_period": "Fiscal years",
"bh_cat_import": "Imports",
"bh_cat_atkomst": "Access",
"bh_cat_ovrigt": "Other",
"bh_empty_title": "No events in the period",
"bh_empty_desc": "The processing history lists posted vouchers, corrections and changes to the system. Pick another fiscal year or date range.",
"bh_error": "Could not load the processing history",
"name_kundreskontra": "Accounts receivable ledger",
"name_supplier_ledger": "Accounts payable ledger",
"name_bank_reconciliation": "Bank reconciliation",
+21
View File
@@ -6622,6 +6622,27 @@
"name_ink2_declaration": "INK2",
"name_huvudbok": "Huvudbok",
"name_grundbok": "Grundbok",
"name_behandlingshistorik": "Behandlingshistorik",
"desc_behandlingshistorik": "Vem gjorde vad och när: bokförda verifikationer, rättelser och ändringar i systemet (BFL 5 kap. 11 §)",
"bh_summary": "{count, plural, =1 {1 händelse} other {# händelser}}",
"bh_range_to": "till",
"bh_version": "Programversion",
"bh_col_time": "Tidpunkt",
"bh_col_event": "Händelse",
"bh_col_actor": "Utförd av",
"bh_col_details": "Detaljer",
"bh_filter_all": "Alla",
"bh_filter_aria": "Filtrera händelser per kategori",
"bh_cat_verifikation": "Verifikationer",
"bh_cat_kontoplan": "Kontoplan",
"bh_cat_installningar": "Inställningar",
"bh_cat_period": "Räkenskapsår",
"bh_cat_import": "Import",
"bh_cat_atkomst": "Åtkomst",
"bh_cat_ovrigt": "Övrigt",
"bh_empty_title": "Inga händelser i perioden",
"bh_empty_desc": "Behandlingshistoriken visar bokförda verifikationer, rättelser och ändringar i systemet. Välj ett annat räkenskapsår eller datumintervall.",
"bh_error": "Kunde inte hämta behandlingshistoriken",
"name_kundreskontra": "Kundreskontra",
"name_supplier_ledger": "Leverantörsreskontra",
"name_bank_reconciliation": "Bankavstämning",