diff --git a/DECISIONS.md b/DECISIONS.md index e8484667..9b76c66b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1150,3 +1150,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/reports/behandlingshistorik/__tests__/route.test.ts b/app/api/reports/behandlingshistorik/__tests__/route.test.ts new file mode 100644 index 00000000..e22678ac --- /dev/null +++ b/app/api/reports/behandlingshistorik/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/reports/behandlingshistorik/route.ts b/app/api/reports/behandlingshistorik/route.ts new file mode 100644 index 00000000..971deb06 --- /dev/null +++ b/app/api/reports/behandlingshistorik/route.ts @@ -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 }) + } +}) diff --git a/components/reports/BehandlingshistorikView.tsx b/components/reports/BehandlingshistorikView.tsx new file mode 100644 index 00000000..d9e44e3c --- /dev/null +++ b/components/reports/BehandlingshistorikView.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('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 ( + + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.events.length === 0) { + return + } + + 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 ( +
+
+
+ +
+ +
+ +

+ {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}` : ''} +

+ + + +
+ + + + + + + + + + + {visible.map((e) => ( + + + + + + + ))} + +
{t('bh_col_time')}{t('bh_col_event')}{t('bh_col_actor')}{t('bh_col_details')}
+ {formatDateTime(e.occurred_at)} + +
{e.event}
+ {e.object && ( +
{e.object}
+ )} +
{e.actor.label} + {e.details.length > 0 && ( +
    + {e.details.map((d, i) => ( +
  • {d}
  • + ))} +
+ )} +
+
+
+
+
+ ) +} diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 90691def..73fb627a 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -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 case 'supplier-ledger': return + case 'behandlingshistorik': + return case 'bank-reconciliation': return ( ): 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 { + 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 + +function makeBuilder(table: string) { + const b: Record = {} + 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) + }) +}) diff --git a/lib/reports/behandlingshistorik-types.ts b/lib/reports/behandlingshistorik-types.ts new file mode 100644 index 00000000..4ce29771 --- /dev/null +++ b/lib/reports/behandlingshistorik-types.ts @@ -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:`, `entry:`. */ + 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 + events: BehandlingshistorikEvent[] +} + +/** Swedish category labels for exports and the statutory document. */ +export const BEHANDLINGSHISTORIK_CATEGORY_LABELS: Record = { + verifikation: 'Verifikationer', + kontoplan: 'Kontoplan', + installningar: 'Inställningar', + period: 'Räkenskapsår', + import: 'Import', + atkomst: 'Åtkomst', + ovrigt: 'Övrigt', +} diff --git a/lib/reports/behandlingshistorik.ts b/lib/reports/behandlingshistorik.ts new file mode 100644 index 00000000..ce35d5ab --- /dev/null +++ b/lib/reports/behandlingshistorik.ts @@ -0,0 +1,1608 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { AuditLogEntry } from '@/types' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + reportToWorkbook, + textColumn, + integerColumn, + exportFilename, + UTF8_BOM, + type SheetSpec, +} from '@/lib/reports/xlsx-export' + +/** + * Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.16). + * + * The statutory processing history has two limbs: + * + * (a) every bokföringspost that entered the system, with its + * registreringsdatum, in order, including corrections and who or what + * registered it; and + * (b) changes to the bookkeeping system that affect how posts are processed + * (kontoplan, settings such as redovisningsmetod or momsperiod, period + * locks, imports and migrations, access keys) and when they were made. + * + * This module is a read model over the stores that already record both limbs + * (`journal_entries.committed_at` + the trigger-written immutable `audit_log`, + * `journal_entry_rattelse_log`, `company_migration_resets`, `sie_imports`, + * `bank_file_imports`) and turns them into one chronological list of + * human-readable events. It never writes. + * + * Event labels are Swedish in both locales: the report is räkenskapsinformation + * that is archived for seven years and handed to revisorer, the same rule as + * the SIE export and the grundbok. UI chrome around it (column headers, + * filters) is translated in the view. + * + * Scope rules: + * - Fiscal-year mode (no date sub-range): the year's bokföringsposter are every + * posted/reversed entry of the fiscal period regardless of when it was + * committed (bokslut and storno entries land after period_end), plus every + * system change logged inside the period's dates. Audit rows touching the + * period's entries are included regardless of their timestamp. + * - Date-range mode (a sub-range inside the fiscal year): what happened between + * those dates: entries committed in the window, audit rows logged in the + * window. No record-id union. + */ + +// ============================================================ +// Public types (shared with the client view via behandlingshistorik-types) +// ============================================================ + +import { + BEHANDLINGSHISTORIK_CATEGORIES, + BEHANDLINGSHISTORIK_CATEGORY_LABELS, + type BehandlingshistorikActorType, + type BehandlingshistorikCategory, + type BehandlingshistorikEvent, + type BehandlingshistorikReport, + type BehandlingshistorikSource, +} from '@/lib/reports/behandlingshistorik-types' + +export { + BEHANDLINGSHISTORIK_CATEGORIES, + BEHANDLINGSHISTORIK_CATEGORY_LABELS, + type BehandlingshistorikActor, + type BehandlingshistorikActorType, + type BehandlingshistorikCategory, + type BehandlingshistorikEvent, + type BehandlingshistorikReport, + type BehandlingshistorikSource, +} from '@/lib/reports/behandlingshistorik-types' + +export interface BehandlingshistorikParams { + periodId: string + /** Inclusive ISO date; must lie inside the fiscal period (validated by the route). */ + fromDate?: string + toDate?: string + categories?: BehandlingshistorikCategory[] +} + +/** Maps user ids to display labels (e-mail). Injected so the lib stays client-agnostic. */ +export type UserLabelResolver = (userIds: string[]) => Promise> + +export interface GenerateBehandlingshistorikOptions { + resolveUserLabels?: UserLabelResolver + appVersion?: string | null + now?: Date +} + +// ============================================================ +// Internal row shapes +// ============================================================ + +interface PeriodRow { + id: string + name: string + period_start: string + period_end: string +} + +interface EntryRow { + id: string + voucher_series: string | null + voucher_number: number | null + entry_date: string + description: string | null + source_type: string | null + status: string + committed_at: string | null + user_id: string | null + committed_actor_type: string | null + committed_actor_label: string | null + commit_method: string | null + reverses_id: string | null + correction_of_id: string | null +} + +interface RattelseRow { + id: string + journal_entry_id: string + rattelse_type: 'metadata' | 'lines' + old_description: string | null + new_description: string | null + old_entry_date: string | null + new_entry_date: string | null + struck_lines: unknown + added_lines: unknown + actor: string | null + created_at: string +} + +interface MigrationResetRow { + id: string + source_company_id: string + replacement_company_id: string + actor_id: string | null + reason: string | null + source_counts: Record | null + created_at: string +} + +interface SieImportRow { + id: string + user_id: string | null + filename: string | null + sie_type: number | string | null + fiscal_year_start: string | null + fiscal_year_end: string | null + accounts_count: number | null + transactions_count: number | null + status: string | null + error_message: string | null + imported_at: string | null + created_at: string + replaced_at: string | null +} + +interface BankFileImportRow { + id: string + user_id: string | null + filename: string | null + file_format: string | null + transaction_count: number | null + imported_count: number | null + duplicate_count: number | null + status: string | null + error_message: string | null + date_from: string | null + date_to: string | null + created_at: string +} + +/** Event before user labels are resolved. */ +interface RawActor { + type: BehandlingshistorikActorType + user_id: string | null + actor_label: string | null +} + +export interface RawBehandlingshistorikEvent extends Omit { + actor: RawActor +} + +// ============================================================ +// Constants: tables, field labels, value labels +// ============================================================ + +/** audit_log tables the report reads. Everything else (registers) is out of scope per BFN's commentary. */ +export const AUDITED_TABLES = [ + 'journal_entries', + 'chart_of_accounts', + 'company_settings', + 'fiscal_periods', + 'api_keys', + 'dimensions', + 'dimension_values', + 'account_dimension_rules', + 'accrual_schedules', + 'document_attachments', +] as const + +/** Actions that matter regardless of table (security / integrity / retention). */ +export const GLOBAL_ACTIONS = [ + 'SECURITY_EVENT', + 'INTEGRITY_FAILURE', + 'RETENTION_BLOCK', + 'DOCUMENT_DELETE_BLOCKED', +] as const + +/** + * PostgREST `or` filter selecting the rows above. Written as one literal so the + * schema guard (tests/schema/no-phantom-columns.test.ts) can resolve the column + * names statically; a unit test pins it to AUDITED_TABLES / GLOBAL_ACTIONS. + */ +export const AUDIT_ROW_FILTER = + 'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)' + +const SOURCE_TYPE_LABELS: Record = { + manual: 'Manuell', + import: 'Import', + bank_transaction: 'Banktransaktion', + storno: 'Storno', + correction: 'Rättelse', + supplier_invoice_registered: 'Leverantörsfaktura registrerad', + supplier_invoice_paid: 'Leverantörsfaktura betald', + supplier_invoice_privately_paid: 'Leverantörsfaktura privat betald', + supplier_invoice_cash_payment: 'Leverantörsfaktura kontant', + supplier_credit_note: 'Leverantörskreditnota', + opening_balance: 'Ingående balans', + system: 'System', + salary_payment: 'Lön', + inbox_item: 'Underlag från inkorg', + invoice_created: 'Kundfaktura', + invoice_paid: 'Kundfaktura betald', + invoice_cash_payment: 'Kontantfaktura', + credit_note: 'Kreditfaktura', + result_appropriation: 'Resultatdisposition', + year_end: 'Bokslut', + vat_settlement: 'Momsavräkning', + accrual: 'Periodisering', + currency_revaluation: 'Valutaomvärdering', + webshop_order: 'Webshop', + stripe_payout: 'Stripe-utbetalning', +} + +const COMMIT_METHOD_LABELS: Record = { + user_accept: 'Godkänd av användare', + bulk_accept: 'Godkänd i massbokning', + api_key: 'Via API-nyckel', + agent_relay: 'Godkänd via assistenten', +} + +const JOURNAL_ENTRY_FIELDS: Record = { + description: 'Beskrivning', + entry_date: 'Datum', + notes: 'Notering', + attachment_urls: 'Underlag', + voucher_series: 'Verifikationsserie', + voucher_number: 'Verifikationsnummer', + fiscal_period_id: 'Räkenskapsår', + status: 'Status', +} + +const ACCOUNT_FIELDS: Record = { + account_name: 'Namn', + account_type: 'Typ', + is_active: 'Aktivt', + default_vat_code: 'Momskod', + default_vat_rate: 'Momssats', + default_vat_treatment: 'Momshantering', + sru_code: 'SRU-kod', + k2_excluded: 'Exkluderat i K2', + description: 'Beskrivning', +} + +/** + * company_settings keys that affect how bokföringsposter are processed + * (BFNAR 2013:2 p. 9.16 second paragraph). Everything else on the row + * (invoice layout, onboarding state, running counters) is deliberately ignored: + * next_invoice_number alone would otherwise add a row per issued invoice. + */ +const SETTINGS_FIELDS: Record = { + accounting_method: 'Redovisningsmetod', + moms_period: 'Momsperiod', + vat_registered: 'Momsregistrerad', + vat_filing_method: 'Momsdeklaration, inlämningssätt', + vat_has_eu_trade: 'EU-handel', + vat_taxable_base_over_40m: 'Beskattningsunderlag över 40 mkr', + tax_turnover_over_40m: 'Omsättning över 40 mkr', + fiscal_year_start_month: 'Räkenskapsårets startmånad', + default_voucher_series: 'Standardserie för verifikat', + default_voucher_series_per_source_type: 'Verifikationsserier per källa', + bookkeeping_locked_through: 'Bokföringen låst till och med', + auto_lock_period_days: 'Automatisk låsning (dagar)', + defer_invoice_booking: 'Bokför kundfakturor vid betalning', + ore_rounding: 'Öresavrundning', + rot_rut_enabled: 'ROT/RUT', + oss_enabled: 'OSS', + ioss_enabled: 'IOSS', + employer_registered: 'Registrerad arbetsgivare', + employer_seasonal: 'Säsongsarbetsgivare', + pays_salaries: 'Betalar löner', + has_employees: 'Har anställda', + employee_count: 'Antal anställda', + salary_pay_day: 'Löneutbetalningsdag', + salary_vacation_year_basis: 'Semesterår', + salary_net_rounding: 'Avrundning nettolön', + salary_default_bank: 'Standardbank för lön', + kontrolluppgifter_enabled: 'Kontrolluppgifter', + fyllnadsinbetalning_enabled: 'Fyllnadsinbetalning', + preliminary_tax_monthly: 'Preliminärskatt per månad', + entity_type: 'Företagsform', + org_number: 'Organisationsnummer', + company_name: 'Företagsnamn', + country: 'Land', + f_skatt: 'F-skatt', + periodisk_sammanstallning_enabled: 'Periodisk sammanställning', + periodisk_sammanstallning_period: 'Periodisk sammanställning, period', + periodisk_sammanstallning_filing_method: 'Periodisk sammanställning, inlämningssätt', + punktskatt_enabled: 'Punktskatt', + intrastat_enabled: 'Intrastat', + dimensions_enabled: 'Dimensioner', + invoice_payment_accounts: 'Betalkonton för kundfakturor', + last_supplier_payment_account: 'Standardkonto för leverantörsbetalning', + schablon_mileage_rate: 'Milersättning (schablon)', + mileage_enabled: 'Körjournal', + selected_modules: 'Aktiverade moduler', + uses_pos_system: 'Kassaregister', + aktiekapital: 'Aktiekapital', + antal_aktier: 'Antal aktier', + is_sandbox: 'Sandlåda', +} + +const SETTINGS_VALUE_LABELS: Record> = { + // company_settings CHECK allows 'accrual' | 'cash'; 'invoice' is the legacy spelling. + accounting_method: { accrual: 'Faktureringsmetoden', invoice: 'Faktureringsmetoden', cash: 'Kontantmetoden' }, + moms_period: { monthly: 'Månad', quarterly: 'Kvartal', yearly: 'Helår', none: 'Ingen' }, + entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma' }, +} + +const PERIOD_FIELDS: Record = { + name: 'Namn', + period_start: 'Startdatum', + period_end: 'Slutdatum', + opening_balances_set: 'Ingående balanser satta', + continuity_verified: 'Kontinuitet verifierad', + tax_depreciation_method: 'Skattemässig avskrivning, metod', + tax_depreciation_rule: 'Skattemässig avskrivning, regel', + tax_depreciation_base: 'Skattemässig avskrivning, underlag', + tax_depreciation_deduction: 'Skattemässig avskrivning, avdrag', +} + +const API_KEY_FIELDS: Record = { + name: 'Namn', + scopes: 'Behörigheter', + rate_limit_per_minute: 'Anrop per minut', + expires_at: 'Giltig till', + is_active: 'Aktiv', +} + +const DIMENSION_FIELDS: Record = { + name: 'Namn', + code: 'Kod', + is_active: 'Aktiv', + dimension_type: 'Typ', + sie_dimension_number: 'SIE-dimension', +} + +const ACCRUAL_FIELDS: Record = { + description: 'Beskrivning', + status: 'Status', + total_amount: 'Belopp', + start_date: 'Startdatum', + end_date: 'Slutdatum', + periods: 'Antal perioder', + balance_account: 'Balanskonto', + result_account: 'Resultatkonto', +} + +/** + * Event codes whose consecutive runs (same actor, short gap) collapse into one + * summary row: kontoplan seeding writes ~1 000 rows, and a receipt clean-up + * deletes dozens of underlag in one go. + */ +interface CollapseRule { + minSize: number + event: string + noun: string + category: BehandlingshistorikCategory + mode: 'accounts' | 'documents' +} + +const COLLAPSIBLE: Record = { + 'account.created': { minSize: 10, event: 'Kontoplan upplagd', noun: 'konton', category: 'kontoplan', mode: 'accounts' }, + 'account.updated': { minSize: 10, event: 'Kontoplan ändrad', noun: 'konton', category: 'kontoplan', mode: 'accounts' }, + 'account.deleted': { minSize: 10, event: 'Konton borttagna ur kontoplanen', noun: 'konton', category: 'kontoplan', mode: 'accounts' }, + 'document.deleted': { minSize: 3, event: 'Underlag borttagna', noun: 'underlag', category: 'ovrigt', mode: 'documents' }, +} + +const MAX_VALUE_LENGTH = 80 + +// ============================================================ +// Small helpers +// ============================================================ + +function toIso(value: string | null | undefined): string | null { + if (!value) return null + const ms = Date.parse(value) + return Number.isNaN(ms) ? value : new Date(ms).toISOString() +} + +function toMs(value: string | null | undefined): number { + if (!value) return Number.NaN + return Date.parse(value) +} + +function isWithin(value: string | null | undefined, fromMs: number, toMs: number): boolean { + const ms = Date.parse(value ?? '') + return !Number.isNaN(ms) && ms >= fromMs && ms <= toMs +} + +function truncate(value: string): string { + return value.length > MAX_VALUE_LENGTH ? `${value.slice(0, MAX_VALUE_LENGTH - 1)}…` : value +} + +function fmtValue(value: unknown, key?: string): string { + if (value === null || value === undefined || value === '') return '(tomt)' + if (typeof value === 'boolean') return value ? 'Ja' : 'Nej' + if (typeof value === 'number') return String(value) + if (typeof value === 'string') { + const mapped = key ? SETTINGS_VALUE_LABELS[key]?.[value] : undefined + return truncate(mapped ?? value) + } + if (Array.isArray(value)) { + if (value.length === 0) return '(tomt)' + return truncate(value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', ')) + } + return truncate(JSON.stringify(value)) +} + +function same(a: unknown, b: unknown): boolean { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null) +} + +/** + * Field-level diff restricted to an allow-list of labelled keys. Returns one + * "Label: old → new" line per changed key, in allow-list order. + */ +export function diffFields( + oldState: Record | null, + newState: Record | null, + labels: Record, +): { lines: string[]; keys: string[] } { + const lines: string[] = [] + const keys: string[] = [] + for (const key of Object.keys(labels)) { + const before = oldState ? oldState[key] : undefined + const after = newState ? newState[key] : undefined + if (same(before, after)) continue + keys.push(key) + lines.push(`${labels[key]}: ${fmtValue(before, key)} → ${fmtValue(after, key)}`) + } + return { lines, keys } +} + +function voucherLabel(series: unknown, number: unknown): string | null { + if (number === null || number === undefined) return null + return `${typeof series === 'string' ? series : ''}${String(number)}` +} + +function str(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function num(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +function sourceTypeLabel(sourceType: string | null | undefined): string { + if (!sourceType) return 'Okänd' + return SOURCE_TYPE_LABELS[sourceType] ?? sourceType +} + +function normaliseActorType(value: unknown): BehandlingshistorikActorType { + switch (value) { + case 'api_key': + case 'mcp_oauth': + case 'cron': + case 'agent_chat': + case 'system': + return value + default: + return 'user' + } +} + +function actorKey(actor: RawActor): string { + return `${actor.type}|${actor.user_id ?? ''}|${actor.actor_label ?? ''}` +} + +/** Resolve a raw actor into the display label shown in the report. */ +export function formatActorLabel(actor: RawActor, userLabels: Map): string { + const userLabel = actor.user_id ? userLabels.get(actor.user_id) : undefined + const fallbackUser = actor.user_id ? `Användare ${actor.user_id.slice(0, 8)}` : null + switch (actor.type) { + case 'api_key': + return actor.actor_label ? `API-nyckel: ${actor.actor_label}` : 'API-nyckel' + case 'mcp_oauth': + return actor.actor_label ? `MCP-anslutning: ${actor.actor_label}` : 'MCP-anslutning' + case 'agent_chat': { + const who = userLabel ?? fallbackUser + return who ? `Assistenten, på uppdrag av ${who}` : 'Assistenten' + } + case 'cron': + return actor.actor_label ? `Schemalagd körning: ${actor.actor_label}` : 'Schemalagd körning' + case 'system': + return actor.actor_label ? `Systemet: ${actor.actor_label}` : 'Systemet' + default: + return userLabel ?? fallbackUser ?? 'Okänd användare' + } +} + +function finaliseEvent( + raw: RawBehandlingshistorikEvent, + userLabels: Map, +): BehandlingshistorikEvent { + return { + ...raw, + actor: { + type: raw.actor.type, + user_id: raw.actor.user_id, + label: formatActorLabel(raw.actor, userLabels), + }, + } +} + +// ============================================================ +// Normalisers: one source row → zero or one event +// ============================================================ + +interface NormaliseContext { + /** journal_entry_id → committed metadata-rättelse timestamps (ms), to suppress the duplicate audit UPDATE row. */ + rattelseMetadataAt: Map + entryById: Map +} + +const RATTELSE_DUPLICATE_WINDOW_MS = 30_000 + +/** A posted or reversed entry, i.e. a real bokföringspost. */ +function isBookedStatus(status: unknown): boolean { + return status === 'posted' || status === 'reversed' +} + +export function commitEventFromEntry(entry: EntryRow): RawBehandlingshistorikEvent | null { + if (!isBookedStatus(entry.status) || !entry.committed_at) return null + const details: string[] = [`Datum: ${entry.entry_date}`] + if (entry.description) details.push(`Text: ${truncate(entry.description)}`) + details.push(`Källa: ${sourceTypeLabel(entry.source_type)}`) + if (entry.commit_method) { + details.push(`Bokföringssätt: ${COMMIT_METHOD_LABELS[entry.commit_method] ?? entry.commit_method}`) + } + if (entry.reverses_id) details.push('Vändningsverifikation (storno)') + if (entry.correction_of_id) details.push('Rättelseverifikation') + const actorType = entry.committed_actor_type + ? normaliseActorType(entry.committed_actor_type) + : entry.commit_method === 'api_key' + ? 'api_key' + : 'user' + return { + id: `entry:${entry.id}`, + occurred_at: toIso(entry.committed_at)!, + category: 'verifikation', + code: 'journal_entry.committed', + event: 'Verifikation bokförd', + object: voucherLabel(entry.voucher_series, entry.voucher_number), + actor: { type: actorType, user_id: entry.user_id, actor_label: entry.committed_actor_label }, + details, + source: 'journal_entries', + count: 1, + } +} + +function describeLine(line: unknown): string { + if (!line || typeof line !== 'object') return String(line) + const l = line as Record + const account = str(l.account_number) ?? '?' + const debit = num(l.debit_amount) ?? 0 + const credit = num(l.credit_amount) ?? 0 + const amount = debit > 0 ? `D ${fmtAmount(debit)}` : `K ${fmtAmount(credit)}` + return `${account} ${amount}` +} + +function fmtAmount(value: number): string { + return value.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export function rattelseEvent( + row: RattelseRow, + entryById: Map, +): RawBehandlingshistorikEvent { + const entry = entryById.get(row.journal_entry_id) + const details: string[] = [] + if (row.rattelse_type === 'metadata') { + if (!same(row.old_description, row.new_description)) { + details.push(`Beskrivning: ${fmtValue(row.old_description)} → ${fmtValue(row.new_description)}`) + } + if (!same(row.old_entry_date, row.new_entry_date)) { + details.push(`Datum: ${fmtValue(row.old_entry_date)} → ${fmtValue(row.new_entry_date)}`) + } + } else { + const struck = Array.isArray(row.struck_lines) ? row.struck_lines : [] + const added = Array.isArray(row.added_lines) ? row.added_lines : [] + if (struck.length > 0) details.push(`Strukna rader (${struck.length}): ${struck.map(describeLine).join('; ')}`) + if (added.length > 0) details.push(`Tillagda rader (${added.length}): ${added.map(describeLine).join('; ')}`) + } + return { + id: `rattelse:${row.id}`, + occurred_at: toIso(row.created_at)!, + category: 'verifikation', + code: row.rattelse_type === 'lines' ? 'journal_entry.corrected_lines' : 'journal_entry.corrected_metadata', + event: + row.rattelse_type === 'lines' + ? 'Verifikation rättad i samma verifikat (rader)' + : 'Verifikation rättad i samma verifikat (text/datum)', + object: entry ? voucherLabel(entry.voucher_series, entry.voucher_number) : null, + actor: { type: 'user', user_id: row.actor, actor_label: null }, + details, + source: 'rattelse_log', + count: 1, + } +} + +function baseAuditActor(row: AuditLogEntry): RawActor { + return { + type: normaliseActorType(row.actor_type), + user_id: row.user_id ?? row.actor_id ?? null, + actor_label: row.actor_label ?? null, + } +} + +function auditEvent( + row: AuditLogEntry, + fields: { + category: BehandlingshistorikCategory + code: string + event: string + object: string | null + details?: string[] + actor?: RawActor + }, +): RawBehandlingshistorikEvent { + return { + id: `audit:${row.id}`, + occurred_at: toIso(row.created_at)!, + category: fields.category, + code: fields.code, + event: fields.event, + object: fields.object, + actor: fields.actor ?? baseAuditActor(row), + details: fields.details ?? [], + source: 'audit_log', + count: 1, + } +} + +function journalEntryAuditEvent( + row: AuditLogEntry, + ctx: NormaliseContext, +): RawBehandlingshistorikEvent | null { + const oldState = row.old_state + const newState = row.new_state + const state = newState ?? oldState + const object = voucherLabel(state?.voucher_series, state?.voucher_number) + + switch (row.action) { + case 'COMMIT': + // The bokföringspost itself is emitted from journal_entries (complete, + // incl. entries predating the audit log). Nothing to add here. + return null + case 'REVERSE': + return auditEvent(row, { + category: 'verifikation', + code: 'journal_entry.reversed', + event: 'Verifikation makulerad (storno)', + object, + details: ['Bokförd verifikation vänd med en vändningsverifikation'], + }) + case 'DELETE': + if (!isBookedStatus(oldState?.status)) return null + return auditEvent(row, { + category: 'verifikation', + code: 'journal_entry.deleted', + event: 'Bokförd verifikation raderad', + object, + details: [`Datum: ${fmtValue(oldState?.entry_date)}`, `Text: ${fmtValue(oldState?.description)}`], + }) + case 'COMMITTED_AT_OVERRIDE': + return auditEvent(row, { + category: 'verifikation', + code: 'journal_entry.committed_at_override', + event: 'Registreringstidpunkt förinställd av systemkörning', + object: object ?? (row.record_id ? `Verifikat ${row.record_id.slice(0, 8)}` : null), + details: [ + `Förinställd tidpunkt: ${fmtValue(newState?.preset_committed_at)}`, + `Verklig tidpunkt: ${fmtValue(newState?.wall_clock)}`, + `Databasroll: ${fmtValue(newState?.jwt_role)}`, + ], + }) + case 'UPDATE': { + if (!isBookedStatus(oldState?.status) && !isBookedStatus(newState?.status)) return null + const { lines, keys } = diffFields(oldState, newState, JOURNAL_ENTRY_FIELDS) + if (lines.length === 0) return null + // A metadata rättelse writes both a rättelse-log row (true actor) and the + // trigger's UPDATE row (entry owner as user_id). Keep the rättelse row. + const onlyRattelseKeys = keys.every((k) => k === 'description' || k === 'entry_date') + if (onlyRattelseKeys && row.record_id) { + const stamps = ctx.rattelseMetadataAt.get(row.record_id) ?? [] + const at = toMs(row.created_at) + if (stamps.some((s) => Math.abs(s - at) <= RATTELSE_DUPLICATE_WINDOW_MS)) return null + } + return auditEvent(row, { + category: 'verifikation', + code: 'journal_entry.updated', + event: 'Verifikation ändrad', + object, + details: lines, + }) + } + default: + return null + } +} + +function accountAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null { + const state = row.new_state ?? row.old_state + const number = str(state?.account_number) + const name = str(state?.account_name) + const object = number ? `${number}${name ? ` ${name}` : ''}` : null + switch (row.action) { + case 'INSERT': { + const details: string[] = [] + const type = str(state?.account_type) + if (type) details.push(`Typ: ${type}`) + const vat = str(state?.default_vat_code) + if (vat) details.push(`Momskod: ${vat}`) + return auditEvent(row, { category: 'kontoplan', code: 'account.created', event: 'Konto tillagt', object, details }) + } + case 'UPDATE': { + const { lines } = diffFields(row.old_state, row.new_state, ACCOUNT_FIELDS) + if (lines.length === 0) return null + return auditEvent(row, { category: 'kontoplan', code: 'account.updated', event: 'Konto ändrat', object, details: lines }) + } + case 'DELETE': + return auditEvent(row, { category: 'kontoplan', code: 'account.deleted', event: 'Konto borttaget', object }) + default: + return null + } +} + +function settingsAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null { + switch (row.action) { + case 'INSERT': { + const s = row.new_state + const details: string[] = [] + if (s?.entity_type) details.push(`Företagsform: ${fmtValue(s.entity_type, 'entity_type')}`) + if (s?.accounting_method) details.push(`Redovisningsmetod: ${fmtValue(s.accounting_method, 'accounting_method')}`) + if (s?.moms_period) details.push(`Momsperiod: ${fmtValue(s.moms_period, 'moms_period')}`) + return auditEvent(row, { + category: 'installningar', + code: 'settings.created', + event: 'Företagsinställningar skapade', + object: str(s?.company_name), + details, + }) + } + case 'UPDATE': { + const { lines } = diffFields(row.old_state, row.new_state, SETTINGS_FIELDS) + if (lines.length === 0) return null + return auditEvent(row, { + category: 'installningar', + code: 'settings.updated', + event: 'Företagsinställningar ändrade', + object: null, + details: lines, + }) + } + case 'DELETE': + return auditEvent(row, { + category: 'installningar', + code: 'settings.deleted', + event: 'Företagsinställningar raderade', + object: str(row.old_state?.company_name), + }) + default: + return null + } +} + +function periodAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null { + const state = row.new_state ?? row.old_state + const name = str(state?.name) + const span = + str(state?.period_start) && str(state?.period_end) + ? `${state?.period_start} till ${state?.period_end}` + : null + const object = name ?? span + switch (row.action) { + case 'INSERT': + return auditEvent(row, { + category: 'period', + code: 'period.created', + event: 'Räkenskapsår skapat', + object, + details: span ? [span] : [], + }) + case 'LOCK_PERIOD': + return auditEvent(row, { category: 'period', code: 'period.locked', event: 'Räkenskapsår låst', object }) + case 'CLOSE_PERIOD': + return auditEvent(row, { + category: 'period', + code: 'period.closed', + event: 'Räkenskapsår stängt (bokslut)', + object, + }) + case 'DELETE': + return auditEvent(row, { category: 'period', code: 'period.deleted', event: 'Räkenskapsår raderat', object }) + case 'UPDATE': { + const oldState = row.old_state + const newState = row.new_state + // App-written unlock rows carry only { locked_at } in both states. + if (oldState?.locked_at && !newState?.locked_at) { + return auditEvent(row, { + category: 'period', + code: 'period.unlocked', + event: 'Räkenskapsår upplåst', + object: object ?? (row.description?.replace(/^Period unlocked: /, '') || null), + }) + } + if (newState?.closed_externally && !oldState?.closed_externally) { + return auditEvent(row, { + category: 'period', + code: 'period.closed_externally', + event: 'Räkenskapsår markerat som stängt i tidigare system', + object: object ?? (row.description?.replace(/^Period marked as closed in previous system: /, '') || null), + }) + } + if (!oldState?.locked_at && newState?.locked_at) { + return auditEvent(row, { category: 'period', code: 'period.locked', event: 'Räkenskapsår låst', object }) + } + if (!oldState?.is_closed && newState?.is_closed) { + return auditEvent(row, { + category: 'period', + code: 'period.closed', + event: 'Räkenskapsår stängt (bokslut)', + object, + }) + } + const { lines } = diffFields(oldState, newState, PERIOD_FIELDS) + if (lines.length === 0) return null + return auditEvent(row, { + category: 'period', + code: 'period.updated', + event: 'Räkenskapsår ändrat', + object, + details: lines, + }) + } + default: + return null + } +} + +function apiKeyAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null { + const state = row.new_state ?? row.old_state + const object = str(state?.name) ?? str(state?.key_prefix) ?? null + switch (row.action) { + case 'INSERT': { + const details: string[] = [] + const scopes = state?.scopes + if (Array.isArray(scopes) && scopes.length > 0) details.push(`Behörigheter: ${fmtValue(scopes)}`) + if (state?.expires_at) details.push(`Giltig till: ${fmtValue(state.expires_at)}`) + return auditEvent(row, { category: 'atkomst', code: 'api_key.created', event: 'API-nyckel skapad', object, details }) + } + case 'UPDATE': { + if (!row.old_state?.revoked_at && row.new_state?.revoked_at) { + return auditEvent(row, { category: 'atkomst', code: 'api_key.revoked', event: 'API-nyckel återkallad', object }) + } + const { lines } = diffFields(row.old_state, row.new_state, API_KEY_FIELDS) + if (lines.length === 0) return null + return auditEvent(row, { category: 'atkomst', code: 'api_key.updated', event: 'API-nyckel ändrad', object, details: lines }) + } + case 'DELETE': + return auditEvent(row, { category: 'atkomst', code: 'api_key.deleted', event: 'API-nyckel raderad', object }) + default: + return null + } +} + +function genericAuditEvent( + row: AuditLogEntry, + opts: { category: BehandlingshistorikCategory; codePrefix: string; noun: string; fields: Record; objectKeys: string[] }, +): RawBehandlingshistorikEvent | null { + const state = row.new_state ?? row.old_state + let object: string | null = null + for (const key of opts.objectKeys) { + const v = str(state?.[key]) + if (v) { + object = object ? `${object} ${v}` : v + } + } + switch (row.action) { + case 'INSERT': + return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.created`, event: `${opts.noun} skapad`, object }) + case 'UPDATE': { + const { lines } = diffFields(row.old_state, row.new_state, opts.fields) + if (lines.length === 0) return null + return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.updated`, event: `${opts.noun} ändrad`, object, details: lines }) + } + case 'DELETE': + return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.deleted`, event: `${opts.noun} borttagen`, object }) + default: + return null + } +} + +function globalActionEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null { + const labels: Record = { + SECURITY_EVENT: { code: 'security.event', event: 'Säkerhetshändelse' }, + INTEGRITY_FAILURE: { code: 'integrity.failure', event: 'Integritetskontroll misslyckades' }, + RETENTION_BLOCK: { code: 'retention.blocked', event: 'Radering stoppad av arkiveringskravet' }, + DOCUMENT_DELETE_BLOCKED: { code: 'document.delete_blocked', event: 'Radering av underlag stoppad' }, + } + const meta = labels[row.action] + if (!meta) return null + return auditEvent(row, { + category: 'ovrigt', + code: meta.code, + event: meta.event, + object: row.table_name ?? null, + details: row.description ? [row.description] : [], + }) +} + +/** One audit_log row → zero or one behandlingshistorik event. Exported for tests. */ +export function auditRowToEvent( + row: AuditLogEntry, + ctx: NormaliseContext = { rattelseMetadataAt: new Map(), entryById: new Map() }, +): RawBehandlingshistorikEvent | null { + if ((GLOBAL_ACTIONS as readonly string[]).includes(row.action)) return globalActionEvent(row) + switch (row.table_name) { + case 'journal_entries': + return journalEntryAuditEvent(row, ctx) + case 'chart_of_accounts': + return accountAuditEvent(row) + case 'company_settings': + return settingsAuditEvent(row) + case 'fiscal_periods': + return periodAuditEvent(row) + case 'api_keys': + return apiKeyAuditEvent(row) + case 'dimensions': + return genericAuditEvent(row, { + category: 'installningar', + codePrefix: 'dimension', + noun: 'Dimension', + fields: DIMENSION_FIELDS, + objectKeys: ['name'], + }) + case 'dimension_values': + return genericAuditEvent(row, { + category: 'installningar', + codePrefix: 'dimension_value', + noun: 'Dimensionsvärde', + fields: DIMENSION_FIELDS, + objectKeys: ['code', 'name'], + }) + case 'account_dimension_rules': + return genericAuditEvent(row, { + category: 'installningar', + codePrefix: 'dimension_rule', + noun: 'Dimensionsregel', + fields: { requirement: 'Krav', account_from: 'Konto från', account_to: 'Konto till' }, + objectKeys: ['account_from', 'account_to'], + }) + case 'accrual_schedules': + return genericAuditEvent(row, { + category: 'installningar', + codePrefix: 'accrual_schedule', + noun: 'Periodiseringsplan', + fields: ACCRUAL_FIELDS, + objectKeys: ['description'], + }) + case 'document_attachments': + if (row.action !== 'DELETE') return null + return auditEvent(row, { + category: 'ovrigt', + code: 'document.deleted', + event: 'Underlag borttaget', + object: str(row.old_state?.file_name), + }) + default: + return null + } +} + +function migrationResetEvent(row: MigrationResetRow, companyId: string): RawBehandlingshistorikEvent { + const isSource = row.source_company_id === companyId + const details: string[] = [] + if (row.reason) details.push(`Skäl: ${truncate(row.reason)}`) + if (row.source_counts && typeof row.source_counts === 'object') { + const parts = Object.entries(row.source_counts) + .filter(([, v]) => typeof v === 'number') + .map(([k, v]) => `${k}: ${v}`) + if (parts.length > 0) details.push(`Omfattning: ${truncate(parts.join(', '))}`) + } + return { + id: `reset:${row.id}`, + occurred_at: toIso(row.created_at)!, + category: 'import', + code: isSource ? 'migration.reset_archived' : 'migration.reset_started', + event: isSource + ? 'Bokföringen arkiverad inför ny migrering' + : 'Nytt bokföringsunderlag startat efter återställning', + object: null, + actor: { type: 'user', user_id: row.actor_id, actor_label: null }, + details, + source: 'migration_reset', + count: 1, + } +} + +function sieImportEvents(row: SieImportRow): RawBehandlingshistorikEvent[] { + const events: RawBehandlingshistorikEvent[] = [] + const actor: RawActor = { type: 'user', user_id: row.user_id, actor_label: null } + const details: string[] = [] + if (row.sie_type !== null && row.sie_type !== undefined) details.push(`Filtyp: SIE${row.sie_type}`) + if (row.fiscal_year_start && row.fiscal_year_end) details.push(`Räkenskapsår: ${row.fiscal_year_start} till ${row.fiscal_year_end}`) + if (typeof row.accounts_count === 'number' || typeof row.transactions_count === 'number') { + details.push(`${row.accounts_count ?? 0} konton, ${row.transactions_count ?? 0} transaktioner`) + } + const completed = row.status === 'completed' + const failed = row.status === 'failed' + events.push({ + id: `sie:${row.id}`, + occurred_at: toIso(completed ? (row.imported_at ?? row.created_at) : row.created_at)!, + category: 'import', + code: completed ? 'sie_import.completed' : failed ? 'sie_import.failed' : 'sie_import.started', + event: completed ? 'SIE-fil importerad' : failed ? 'SIE-import misslyckades' : 'SIE-import påbörjad', + object: row.filename, + actor, + details: failed && row.error_message ? [...details, `Fel: ${truncate(row.error_message)}`] : details, + source: 'sie_import', + count: 1, + }) + if (row.replaced_at) { + events.push({ + id: `sie:${row.id}:replaced`, + occurred_at: toIso(row.replaced_at)!, + category: 'import', + code: 'sie_import.replaced', + event: 'SIE-import ersatt av ny import', + object: row.filename, + actor, + details: [], + source: 'sie_import', + count: 1, + }) + } + return events +} + +function bankFileImportEvent(row: BankFileImportRow): RawBehandlingshistorikEvent { + const failed = row.status === 'failed' + const details: string[] = [] + if (row.file_format) details.push(`Format: ${row.file_format}`) + if (typeof row.transaction_count === 'number') { + details.push( + `${row.imported_count ?? 0} av ${row.transaction_count} transaktioner importerade` + + (row.duplicate_count ? `, ${row.duplicate_count} dubbletter` : ''), + ) + } + if (row.date_from && row.date_to) details.push(`Kontoutdrag: ${row.date_from} till ${row.date_to}`) + if (failed && row.error_message) details.push(`Fel: ${truncate(row.error_message)}`) + return { + id: `bankfile:${row.id}`, + occurred_at: toIso(row.created_at)!, + category: 'import', + code: failed ? 'bank_file_import.failed' : 'bank_file_import.completed', + event: failed ? 'Bankfilsimport misslyckades' : 'Bankfil importerad', + object: row.filename, + actor: { type: 'user', user_id: row.user_id, actor_label: null }, + details, + source: 'bank_file_import', + count: 1, + } +} + +// ============================================================ +// Ordering and burst collapse +// ============================================================ + +const SOURCE_ORDER: Record = { + journal_entries: 0, + audit_log: 1, + rattelse_log: 2, + migration_reset: 3, + sie_import: 4, + bank_file_import: 5, +} + +export function sortEvents( + events: T[], +): T[] { + return [...events].sort((a, b) => { + const ta = toMs(a.occurred_at) + const tb = toMs(b.occurred_at) + if (ta !== tb) return ta - tb + const sa = SOURCE_ORDER[a.source] + const sb = SOURCE_ORDER[b.source] + if (sa !== sb) return sa - sb + return a.id.localeCompare(b.id) + }) +} + +export interface CollapseOptions { + /** Runs shorter than this are kept as individual events. */ + minSize?: number + /** Max gap between consecutive rows of one run. */ + gapMs?: number +} + +/** + * Chart-of-accounts seeding and bulk maintenance write one audit row per + * account (a BAS kontoplan is ~1 000 rows), and a receipt clean-up deletes + * many underlag at once. Consecutive events of a collapsible code by the same + * actor within a short gap collapse into one summary event ("Kontoplan + * upplagd: 41 konton") so the report reads as what happened, not as a wall of + * rows. Input must already be sorted by occurred_at. + */ +export function collapseBursts( + events: RawBehandlingshistorikEvent[], + options: CollapseOptions = {}, +): RawBehandlingshistorikEvent[] { + const gapMs = options.gapMs ?? 120_000 + const out: RawBehandlingshistorikEvent[] = [] + let run: RawBehandlingshistorikEvent[] = [] + + const flush = () => { + if (run.length === 0) return + const rule = COLLAPSIBLE[run[0].code] + const minSize = options.minSize ?? rule.minSize + if (run.length < minSize) { + out.push(...run) + } else { + out.push(summariseRun(run, rule)) + } + run = [] + } + + for (const ev of events) { + if (!COLLAPSIBLE[ev.code]) { + flush() + out.push(ev) + continue + } + const prev = run[run.length - 1] + const continues = + prev && + prev.code === ev.code && + actorKey(prev.actor) === actorKey(ev.actor) && + toMs(ev.occurred_at) - toMs(prev.occurred_at) <= gapMs + if (!continues) flush() + run.push(ev) + } + flush() + return out +} + +/** @deprecated name kept for readers of the first revision; use collapseBursts. */ +export const collapseAccountBursts = collapseBursts + +const MAX_LISTED_OBJECTS = 5 + +function summariseRun(run: RawBehandlingshistorikEvent[], rule: CollapseRule): RawBehandlingshistorikEvent { + const first = run[0] + const details: string[] = [] + if (rule.mode === 'accounts') { + const numbers = run + .map((e) => (e.object ? e.object.split(' ')[0] : '')) + .filter((n) => n.length > 0) + .sort() + if (numbers.length > 0) details.push(`Konton ${numbers[0]} till ${numbers[numbers.length - 1]}`) + if (first.code === 'account.updated') { + const changed = new Set() + for (const e of run) for (const d of e.details) changed.add(d.split(':')[0]) + if (changed.size > 0) details.push(`Ändrade fält: ${[...changed].join(', ')}`) + } + } else { + const names = [...new Set(run.map((e) => e.object).filter((o): o is string => !!o))] + if (names.length > 0) { + const listed = names.slice(0, MAX_LISTED_OBJECTS).join(', ') + const rest = names.length - MAX_LISTED_OBJECTS + details.push(rest > 0 ? `${listed} och ${rest} till` : listed) + } + } + return { + id: `${first.id}:bulk`, + occurred_at: first.occurred_at, + category: rule.category, + code: `${first.code}.bulk`, + event: rule.event, + object: `${run.length} ${rule.noun}`, + actor: first.actor, + details, + source: 'audit_log', + count: run.length, + } +} + +// ============================================================ +// Fetchers +// ============================================================ + +const ID_CHUNK = 200 + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = [] + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) + return out +} + +async function fetchPeriod(supabase: SupabaseClient, companyId: string, periodId: string): Promise { + const { data, error } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .maybeSingle() + if (error) throw new Error(`Failed to fetch fiscal period: ${error.message}`) + return (data as PeriodRow | null) ?? null +} + +async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise<{ name: string; org_number: string | null }> { + const { data } = await supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .maybeSingle() + const row = data as { company_name?: string | null; org_number?: string | null } | null + return { name: row?.company_name ?? '', org_number: row?.org_number ?? null } +} + +async function fetchPeriodEntries(supabase: SupabaseClient, companyId: string, periodId: string): Promise { + return fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select( + 'id, voucher_series, voucher_number, entry_date, description, source_type, status, committed_at, user_id, committed_actor_type, committed_actor_label, commit_method, reverses_id, correction_of_id', + ) + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .order('id', { ascending: true }) + .range(from, to), + ) +} + +async function fetchAuditRows( + supabase: SupabaseClient, + companyId: string, + window: { fromTs: string; toTs: string }, + recordIds: string[], +): Promise { + const byId = new Map() + + const windowed = await fetchAllRows(({ from, to }) => + supabase + .from('audit_log') + .select('*') + .eq('company_id', companyId) + .gte('created_at', window.fromTs) + .lte('created_at', window.toTs) + // Literal on purpose (not AUDIT_ROW_FILTER): the schema guard only + // resolves string literals here. A test pins the two to each other. + .or( + 'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)', + ) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const row of windowed) byId.set(row.id, row) + + for (const ids of chunk(recordIds, ID_CHUNK)) { + const rows = await fetchAllRows(({ from, to }) => + supabase + .from('audit_log') + .select('*') + .eq('company_id', companyId) + .eq('table_name', 'journal_entries') + .in('record_id', ids) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const row of rows) byId.set(row.id, row) + } + return [...byId.values()] +} + +async function fetchRattelseRows( + supabase: SupabaseClient, + companyId: string, + window: { fromTs: string; toTs: string }, + entryIds: string[], +): Promise { + const byId = new Map() + const windowed = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entry_rattelse_log') + .select('*') + .eq('company_id', companyId) + .gte('created_at', window.fromTs) + .lte('created_at', window.toTs) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const row of windowed) byId.set(row.id, row) + for (const ids of chunk(entryIds, ID_CHUNK)) { + const rows = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entry_rattelse_log') + .select('*') + .eq('company_id', companyId) + .in('journal_entry_id', ids) + .order('id', { ascending: true }) + .range(from, to), + ) + for (const row of rows) byId.set(row.id, row) + } + return [...byId.values()] +} + +async function fetchMigrationResets(supabase: SupabaseClient, companyId: string): Promise { + // Two equality queries instead of one `.or()`: the reset row is relevant to + // both the archived source company and its replacement, and the schema guard + // resolves plain column filters statically. + const byId = new Map() + const asSource = await supabase + .from('company_migration_resets') + .select('id, source_company_id, replacement_company_id, actor_id, reason, source_counts, created_at') + .eq('source_company_id', companyId) + .order('created_at', { ascending: true }) + if (asSource.error) throw new Error(`Failed to fetch migration resets: ${asSource.error.message}`) + const asReplacement = await supabase + .from('company_migration_resets') + .select('id, source_company_id, replacement_company_id, actor_id, reason, source_counts, created_at') + .eq('replacement_company_id', companyId) + .order('created_at', { ascending: true }) + if (asReplacement.error) throw new Error(`Failed to fetch migration resets: ${asReplacement.error.message}`) + for (const row of [ + ...((asSource.data as MigrationResetRow[] | null) ?? []), + ...((asReplacement.data as MigrationResetRow[] | null) ?? []), + ]) { + byId.set(row.id, row) + } + return [...byId.values()] +} + +async function fetchSieImports(supabase: SupabaseClient, companyId: string): Promise { + return fetchAllRows(({ from, to }) => + supabase + .from('sie_imports') + .select( + 'id, user_id, filename, sie_type, fiscal_year_start, fiscal_year_end, accounts_count, transactions_count, status, error_message, imported_at, created_at, replaced_at', + ) + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to), + ) +} + +async function fetchBankFileImports(supabase: SupabaseClient, companyId: string): Promise { + return fetchAllRows(({ from, to }) => + supabase + .from('bank_file_imports') + .select( + 'id, user_id, filename, file_format, transaction_count, imported_count, duplicate_count, status, error_message, date_from, date_to, created_at', + ) + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to), + ) +} + +/** + * Resolve user ids to e-mail labels through `profiles`. `profiles` RLS is + * self-only, so the caller passes a service-role client; the lookup is scoped + * to exactly the ids that appear in the report. + */ +export async function resolveUserLabelsFromProfiles( + serviceClient: Pick, + userIds: string[], +): Promise> { + const labels = new Map() + for (const ids of chunk(userIds, ID_CHUNK)) { + const { data } = await serviceClient.from('profiles').select('id, email, full_name').in('id', ids) + for (const row of (data as { id: string; email: string | null; full_name: string | null }[] | null) ?? []) { + const label = row.email || row.full_name + if (label) labels.set(row.id, label) + } + } + return labels +} + +// ============================================================ +// Generator +// ============================================================ + +export async function generateBehandlingshistorik( + supabase: SupabaseClient, + companyId: string, + params: BehandlingshistorikParams, + options: GenerateBehandlingshistorikOptions = {}, +): Promise { + const period = await fetchPeriod(supabase, companyId, params.periodId) + if (!period) return null + const company = await fetchCompany(supabase, companyId) + + const mode: BehandlingshistorikReport['mode'] = params.fromDate || params.toDate ? 'date_range' : 'fiscal_year' + const from = params.fromDate ?? period.period_start + const to = params.toDate ?? period.period_end + const fromTs = `${from}T00:00:00.000Z` + const toTs = `${to}T23:59:59.999Z` + const fromMs = Date.parse(fromTs) + const toMsBound = Date.parse(toTs) + const window = { fromTs, toTs } + + const entries = await fetchPeriodEntries(supabase, companyId, period.id) + const entryById = new Map(entries.map((e) => [e.id, e])) + const entryIds = entries.map((e) => e.id) + const unionIds = mode === 'fiscal_year' ? entryIds : [] + + const [auditRows, rattelseRows, resets, sieImports, bankImports] = await Promise.all([ + fetchAuditRows(supabase, companyId, window, unionIds), + fetchRattelseRows(supabase, companyId, window, unionIds), + fetchMigrationResets(supabase, companyId), + fetchSieImports(supabase, companyId), + fetchBankFileImports(supabase, companyId), + ]) + + const raw: RawBehandlingshistorikEvent[] = [] + + // (a) bokföringsposter: from journal_entries, the complete source. + for (const entry of entries) { + if (mode === 'date_range' && !isWithin(entry.committed_at, fromMs, toMsBound)) continue + const ev = commitEventFromEntry(entry) + if (ev) raw.push(ev) + } + + // Rättelser, with an index so the duplicate trigger row can be suppressed. + const rattelseMetadataAt = new Map() + for (const row of rattelseRows) { + if (row.rattelse_type === 'metadata') { + const list = rattelseMetadataAt.get(row.journal_entry_id) ?? [] + list.push(toMs(row.created_at)) + rattelseMetadataAt.set(row.journal_entry_id, list) + } + raw.push(rattelseEvent(row, entryById)) + } + + const ctx: NormaliseContext = { rattelseMetadataAt, entryById } + for (const row of auditRows) { + const ev = auditRowToEvent(row, ctx) + if (ev) raw.push(ev) + } + + // (b) imports and migrations: filtered to the window in code (small tables). + for (const row of resets) { + if (isWithin(row.created_at, fromMs, toMsBound)) raw.push(migrationResetEvent(row, companyId)) + } + for (const row of sieImports) { + for (const ev of sieImportEvents(row)) { + if (isWithin(ev.occurred_at, fromMs, toMsBound)) raw.push(ev) + } + } + for (const row of bankImports) { + if (isWithin(row.created_at, fromMs, toMsBound)) raw.push(bankFileImportEvent(row)) + } + + let events = collapseBursts(sortEvents(raw)) + if (params.categories && params.categories.length > 0) { + const wanted = new Set(params.categories) + events = events.filter((e) => wanted.has(e.category)) + } + + const userIds = [...new Set(events.map((e) => e.actor.user_id).filter((id): id is string => !!id))] + const userLabels = options.resolveUserLabels && userIds.length > 0 + ? await options.resolveUserLabels(userIds) + : new Map() + const finalEvents = events.map((e) => finaliseEvent(e, userLabels)) + + const byCategory = Object.fromEntries( + BEHANDLINGSHISTORIK_CATEGORIES.map((c) => [c, 0]), + ) as Record + for (const e of finalEvents) byCategory[e.category] += 1 + + return { + company, + period: { id: period.id, name: period.name, start: period.period_start, end: period.period_end }, + range: { from, to }, + mode, + generated_at: (options.now ?? new Date()).toISOString(), + app_version: options.appVersion ?? null, + total_events: finalEvents.length, + by_category: byCategory, + events: finalEvents, + } +} + +// ============================================================ +// Export (xlsx / csv) +// ============================================================ + +const STOCKHOLM_FORMAT = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Europe/Stockholm', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}) + +/** `2026-08-21 10:15:03` in Swedish local time. Falls back to the input when unparseable. */ +export function formatStockholmTimestamp(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return STOCKHOLM_FORMAT.format(d).replace(',', '') +} + +export function behandlingshistorikSheetSpecs(report: BehandlingshistorikReport): SheetSpec[] { + const events: SheetSpec = { + name: 'Behandlingshistorik', + columns: [ + textColumn('Tidpunkt'), + textColumn('Kategori'), + textColumn('Händelse'), + textColumn('Objekt'), + textColumn('Utförd av'), + textColumn('Detaljer'), + textColumn('Kod'), + integerColumn('Antal'), + ], + rows: report.events, + mapRow: (e) => [ + formatStockholmTimestamp(e.occurred_at), + BEHANDLINGSHISTORIK_CATEGORY_LABELS[e.category], + e.event, + e.object, + e.actor.label, + e.details.join(' | '), + e.code, + e.count, + ], + } + const meta: SheetSpec<[string, string]> = { + name: 'Rapport', + columns: [textColumn('Uppgift'), textColumn('Värde')], + rows: [ + ['Rapport', 'Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16)'], + ['Företag', report.company.name], + ['Organisationsnummer', report.company.org_number ?? ''], + ['Räkenskapsår', `${report.period.name} (${report.period.start} till ${report.period.end})`], + ['Urval', report.mode === 'fiscal_year' ? 'Hela räkenskapsåret' : `${report.range.from} till ${report.range.to}`], + ['Antal händelser', String(report.total_events)], + ['Genererad', formatStockholmTimestamp(report.generated_at)], + ['Programversion', report.app_version ?? 'okänd'], + ['Tidszon', 'Europe/Stockholm'], + ], + mapRow: (r) => [r[0], r[1]], + } + return [events as SheetSpec, meta as SheetSpec] +} + +export function buildBehandlingshistorikExport( + report: BehandlingshistorikReport, + format: 'xlsx' | 'csv', +): { buffer: Buffer; contentType: string; filename: string } { + const specs = behandlingshistorikSheetSpecs(report) + const date = report.mode === 'fiscal_year' ? report.period.end : report.range.to + if (format === 'csv') { + // CSV carries the first sheet only; the metadata sheet is xlsx-only. + const buf = reportToWorkbook([specs[0]], { bookType: 'csv' }) + // SheetJS already emits a UTF-8 BOM for csv in current versions; only add + // one when it is missing so Excel never sees a doubled mark in cell A1. + const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf + return { + buffer: hasBom ? buf : Buffer.concat([Buffer.from(UTF8_BOM, 'utf-8'), buf]), + contentType: 'text/csv; charset=utf-8', + filename: exportFilename('behandlingshistorik', report.company.name, date, 'csv'), + } + } + return { + buffer: reportToWorkbook(specs), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: exportFilename('behandlingshistorik', report.company.name, date, 'xlsx'), + } +} diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 16827b7b..26d16025 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -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. */ diff --git a/messages/en.json b/messages/en.json index 42fda610..6a174918 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 766ef58c..1ad6d804 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",