feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) (#1787)
* feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) Adds the per-räkenskapsår processing history as a first-class report in Rapporter (Export & arkiv), with CSV/XLSX export. Until now the behandlingshistorik only existed as raw audit_log JSON inside the Säkerhetsbackup ZIP; revisorer ask for a readable per-year document. - lib/reports/behandlingshistorik.ts: read model over journal_entries (committed_at = registreringsdatum, the complete source of bokföringsposter), the trigger-written audit_log (storno, deletions, diffs, kontoplan, settings, period lock/unlock/close, API keys, dimensions, accruals), the rättelse log, company_migration_resets, sie_imports and bank_file_imports. Field-level diffs with Swedish labels; company_settings restricted to processing-relevant keys (p. 9.16 second paragraph); kontoplan seeding and bulk underlag deletions collapse into one summary row; actor labels for users, API keys, MCP, agent, cron and system; fiscal-year mode unions audit rows touching the year's entries regardless of timestamp (bokslut/storno land after period_end), date-range mode narrows by registration time. - GET /api/reports/behandlingshistorik?period_id&from_date&to_date&category&format (json|csv|xlsx), withRouteContext + Zod, e-mail labels via service-role profiles lookup scoped to the ids in the result, app version stamped. - Report catalog row + focused view (category filter, export menu), sv/en. - Tests: 30 read-model tests, 10 route tests; smoke-tested read-only on prod. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi * fix(reports): keep behandlingshistorik queries statically resolvable for the schema guard tests/schema/no-phantom-columns.test.ts counts `.or()` calls with non-literal arguments as unresolvable and holds a ceiling (379); the report added two. The audit_log table/action filter is now a string literal in the call (pinned to AUDITED_TABLES / GLOBAL_ACTIONS by a unit test), and the migration-reset lookup is two plain `.eq()` queries instead of an interpolated `.or()`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
d3409183c0
commit
4be51aae67
@@ -2495,6 +2495,21 @@ export const AuditTrailQuerySchema = z.object({
|
||||
page_size: z.coerce.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/reports/behandlingshistorik (BFL 5 kap. 11 §). period_id is the
|
||||
* fiscal period; from_date/to_date narrow to a sub-range inside it (validated
|
||||
* against the period bounds by the route, like the other fiscal-range reports).
|
||||
*/
|
||||
export const BehandlingshistorikQuerySchema = z.object({
|
||||
period_id: z.string().min(1),
|
||||
from_date: isoDate.optional(),
|
||||
to_date: isoDate.optional(),
|
||||
category: z
|
||||
.enum(['verifikation', 'kontoplan', 'installningar', 'period', 'import', 'atkomst', 'ovrigt'])
|
||||
.optional(),
|
||||
format: z.enum(['json', 'csv', 'xlsx']).default('json'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Voucher gap schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { AuditLogEntry } from '@/types'
|
||||
import {
|
||||
AUDITED_TABLES,
|
||||
AUDIT_ROW_FILTER,
|
||||
GLOBAL_ACTIONS,
|
||||
auditRowToEvent,
|
||||
buildBehandlingshistorikExport,
|
||||
collapseBursts,
|
||||
commitEventFromEntry,
|
||||
diffFields,
|
||||
formatActorLabel,
|
||||
formatStockholmTimestamp,
|
||||
generateBehandlingshistorik,
|
||||
rattelseEvent,
|
||||
sortEvents,
|
||||
type RawBehandlingshistorikEvent,
|
||||
} from '../behandlingshistorik'
|
||||
|
||||
// ============================================================
|
||||
// Fixtures
|
||||
// ============================================================
|
||||
|
||||
let seq = 0
|
||||
function auditRow(overrides: Partial<AuditLogEntry>): AuditLogEntry {
|
||||
seq += 1
|
||||
return {
|
||||
id: `audit-${seq}`,
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
action: 'UPDATE',
|
||||
table_name: 'journal_entries',
|
||||
record_id: 'rec-1',
|
||||
actor_id: 'user-1',
|
||||
actor_type: 'user',
|
||||
actor_label: null,
|
||||
old_state: null,
|
||||
new_state: null,
|
||||
description: null,
|
||||
created_at: '2026-03-10T10:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function rawEvent(overrides: Partial<RawBehandlingshistorikEvent>): RawBehandlingshistorikEvent {
|
||||
seq += 1
|
||||
return {
|
||||
id: `ev-${seq}`,
|
||||
occurred_at: '2026-03-10T10:00:00.000Z',
|
||||
category: 'kontoplan',
|
||||
code: 'account.created',
|
||||
event: 'Konto tillagt',
|
||||
object: '1930 Företagskonto',
|
||||
actor: { type: 'user', user_id: 'user-1', actor_label: null },
|
||||
details: [],
|
||||
source: 'audit_log',
|
||||
count: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const baseEntry = {
|
||||
id: 'entry-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 12,
|
||||
entry_date: '2026-03-09',
|
||||
description: 'Hyra mars',
|
||||
source_type: 'manual',
|
||||
status: 'posted',
|
||||
committed_at: '2026-03-10T09:30:00.000Z',
|
||||
user_id: 'user-1',
|
||||
committed_actor_type: null,
|
||||
committed_actor_label: null,
|
||||
commit_method: 'user_accept',
|
||||
reverses_id: null,
|
||||
correction_of_id: null,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// audit_log row filter: the literal in the query must track the constants
|
||||
// ============================================================
|
||||
|
||||
describe('AUDIT_ROW_FILTER', () => {
|
||||
it('is built from AUDITED_TABLES and GLOBAL_ACTIONS', () => {
|
||||
expect(AUDIT_ROW_FILTER).toBe(
|
||||
`table_name.in.(${AUDITED_TABLES.join(',')}),action.in.(${GLOBAL_ACTIONS.join(',')})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('is the exact literal used in the audit_log query (schema guard needs a literal there)', () => {
|
||||
const source = readFileSync(path.join(__dirname, '..', 'behandlingshistorik.ts'), 'utf-8')
|
||||
expect(source).toContain(`.or(\n '${AUDIT_ROW_FILTER}',\n )`)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// diffFields
|
||||
// ============================================================
|
||||
|
||||
describe('diffFields', () => {
|
||||
it('reports only allow-listed keys that changed, in allow-list order', () => {
|
||||
const { lines, keys } = diffFields(
|
||||
{ a: 1, b: 'x', c: true, noise: 1 },
|
||||
{ a: 1, b: 'y', c: false, noise: 2 },
|
||||
{ c: 'C-etikett', b: 'B-etikett' },
|
||||
)
|
||||
expect(keys).toEqual(['c', 'b'])
|
||||
expect(lines).toEqual(['C-etikett: Ja → Nej', 'B-etikett: x → y'])
|
||||
})
|
||||
|
||||
it('renders null/empty as (tomt) and maps known setting values to Swedish', () => {
|
||||
const { lines } = diffFields(
|
||||
{ accounting_method: null },
|
||||
{ accounting_method: 'cash' },
|
||||
{ accounting_method: 'Redovisningsmetod' },
|
||||
)
|
||||
expect(lines).toEqual(['Redovisningsmetod: (tomt) → Kontantmetoden'])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// commitEventFromEntry
|
||||
// ============================================================
|
||||
|
||||
describe('commitEventFromEntry', () => {
|
||||
it('turns a posted entry into a bokförd event with registreringsdatum = committed_at', () => {
|
||||
const ev = commitEventFromEntry(baseEntry)!
|
||||
expect(ev).toMatchObject({
|
||||
id: 'entry:entry-1',
|
||||
occurred_at: '2026-03-10T09:30:00.000Z',
|
||||
category: 'verifikation',
|
||||
code: 'journal_entry.committed',
|
||||
event: 'Verifikation bokförd',
|
||||
object: 'A12',
|
||||
source: 'journal_entries',
|
||||
actor: { type: 'user', user_id: 'user-1' },
|
||||
})
|
||||
expect(ev.details).toEqual([
|
||||
'Datum: 2026-03-09',
|
||||
'Text: Hyra mars',
|
||||
'Källa: Manuell',
|
||||
'Bokföringssätt: Godkänd av användare',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips drafts and cancelled entries', () => {
|
||||
expect(commitEventFromEntry({ ...baseEntry, status: 'draft', committed_at: null })).toBeNull()
|
||||
expect(commitEventFromEntry({ ...baseEntry, status: 'cancelled' })).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the machine actor from committed_actor_type / label', () => {
|
||||
const ev = commitEventFromEntry({
|
||||
...baseEntry,
|
||||
committed_actor_type: 'api_key',
|
||||
committed_actor_label: 'Zapier',
|
||||
commit_method: 'api_key',
|
||||
})!
|
||||
expect(ev.actor).toEqual({ type: 'api_key', user_id: 'user-1', actor_label: 'Zapier' })
|
||||
})
|
||||
|
||||
it('marks storno and rättelse vouchers', () => {
|
||||
const storno = commitEventFromEntry({ ...baseEntry, reverses_id: 'x', source_type: 'storno' })!
|
||||
expect(storno.details).toContain('Vändningsverifikation (storno)')
|
||||
const corr = commitEventFromEntry({ ...baseEntry, correction_of_id: 'x', source_type: 'correction' })!
|
||||
expect(corr.details).toContain('Rättelseverifikation')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// auditRowToEvent
|
||||
// ============================================================
|
||||
|
||||
describe('auditRowToEvent: journal_entries', () => {
|
||||
it('ignores COMMIT rows (the bokföringspost comes from journal_entries)', () => {
|
||||
expect(auditRowToEvent(auditRow({ action: 'COMMIT', new_state: { status: 'posted' } }))).toBeNull()
|
||||
})
|
||||
|
||||
it('emits REVERSE as makulerad with the voucher label', () => {
|
||||
const ev = auditRowToEvent(
|
||||
auditRow({ action: 'REVERSE', old_state: { voucher_series: 'A', voucher_number: 5, status: 'posted' }, new_state: { voucher_series: 'A', voucher_number: 5, status: 'reversed' } }),
|
||||
)!
|
||||
expect(ev).toMatchObject({ code: 'journal_entry.reversed', object: 'A5', category: 'verifikation' })
|
||||
})
|
||||
|
||||
it('emits DELETE only for booked entries', () => {
|
||||
expect(auditRowToEvent(auditRow({ action: 'DELETE', old_state: { status: 'draft', voucher_series: 'A', voucher_number: null } }))).toBeNull()
|
||||
const ev = auditRowToEvent(
|
||||
auditRow({ action: 'DELETE', old_state: { status: 'posted', voucher_series: 'A', voucher_number: 7, entry_date: '2026-01-02', description: 'Fel' } }),
|
||||
)!
|
||||
expect(ev).toMatchObject({ code: 'journal_entry.deleted', object: 'A7' })
|
||||
expect(ev.details).toEqual(['Datum: 2026-01-02', 'Text: Fel'])
|
||||
})
|
||||
|
||||
it('emits UPDATE diffs on booked entries and skips draft edits / no-op updates', () => {
|
||||
const draft = auditRow({ action: 'UPDATE', old_state: { status: 'draft', description: 'a' }, new_state: { status: 'draft', description: 'b' } })
|
||||
expect(auditRowToEvent(draft)).toBeNull()
|
||||
|
||||
const noop = auditRow({ action: 'UPDATE', old_state: { status: 'posted', updated_at: '1' }, new_state: { status: 'posted', updated_at: '2' } })
|
||||
expect(auditRowToEvent(noop)).toBeNull()
|
||||
|
||||
const ev = auditRowToEvent(
|
||||
auditRow({
|
||||
action: 'UPDATE',
|
||||
old_state: { status: 'posted', voucher_series: 'A', voucher_number: 3, notes: null },
|
||||
new_state: { status: 'posted', voucher_series: 'A', voucher_number: 3, notes: 'Kvitto saknas' },
|
||||
}),
|
||||
)!
|
||||
expect(ev).toMatchObject({ code: 'journal_entry.updated', object: 'A3' })
|
||||
expect(ev.details).toEqual(['Notering: (tomt) → Kvitto saknas'])
|
||||
})
|
||||
|
||||
it('suppresses the trigger UPDATE row that duplicates a metadata rättelse', () => {
|
||||
const row = auditRow({
|
||||
action: 'UPDATE',
|
||||
record_id: 'entry-9',
|
||||
created_at: '2026-03-10T10:00:05.000Z',
|
||||
old_state: { status: 'posted', description: 'a', voucher_series: 'A', voucher_number: 9 },
|
||||
new_state: { status: 'posted', description: 'b', voucher_series: 'A', voucher_number: 9 },
|
||||
})
|
||||
const ctx = {
|
||||
rattelseMetadataAt: new Map([['entry-9', [Date.parse('2026-03-10T10:00:00.000Z')]]]),
|
||||
entryById: new Map(),
|
||||
}
|
||||
expect(auditRowToEvent(row, ctx)).toBeNull()
|
||||
// A different entry, or a change that is not just description/date, is kept.
|
||||
expect(auditRowToEvent({ ...row, record_id: 'entry-8' }, ctx)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits COMMITTED_AT_OVERRIDE with preset vs wall clock', () => {
|
||||
const ev = auditRowToEvent(
|
||||
auditRow({
|
||||
action: 'COMMITTED_AT_OVERRIDE',
|
||||
actor_type: 'system',
|
||||
new_state: { preset_committed_at: '2025-01-01T00:00:00Z', wall_clock: '2026-08-17T10:00:00Z', jwt_role: 'service_role' },
|
||||
}),
|
||||
)!
|
||||
expect(ev.code).toBe('journal_entry.committed_at_override')
|
||||
expect(ev.actor.type).toBe('system')
|
||||
expect(ev.details[0]).toContain('2025-01-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('auditRowToEvent: system changes', () => {
|
||||
it('kontoplan: INSERT / UPDATE diff / DELETE, and no-op UPDATE is dropped', () => {
|
||||
const ins = auditRowToEvent(
|
||||
auditRow({ table_name: 'chart_of_accounts', action: 'INSERT', new_state: { account_number: '6540', account_name: 'IT-tjänster', account_type: 'expense', default_vat_code: '25' } }),
|
||||
)!
|
||||
expect(ins).toMatchObject({ category: 'kontoplan', code: 'account.created', object: '6540 IT-tjänster' })
|
||||
expect(ins.details).toEqual(['Typ: expense', 'Momskod: 25'])
|
||||
|
||||
const upd = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'chart_of_accounts',
|
||||
action: 'UPDATE',
|
||||
old_state: { account_number: '6540', account_name: 'IT-tjänster', default_vat_code: '25', updated_at: 'x' },
|
||||
new_state: { account_number: '6540', account_name: 'Programvaror', default_vat_code: '25', updated_at: 'y' },
|
||||
}),
|
||||
)!
|
||||
expect(upd.details).toEqual(['Namn: IT-tjänster → Programvaror'])
|
||||
|
||||
const noop = auditRowToEvent(
|
||||
auditRow({ table_name: 'chart_of_accounts', action: 'UPDATE', old_state: { account_number: '6540', sort_order: 1 }, new_state: { account_number: '6540', sort_order: 2 } }),
|
||||
)
|
||||
expect(noop).toBeNull()
|
||||
|
||||
const del = auditRowToEvent(auditRow({ table_name: 'chart_of_accounts', action: 'DELETE', old_state: { account_number: '6540', account_name: 'X' } }))!
|
||||
expect(del.code).toBe('account.deleted')
|
||||
})
|
||||
|
||||
it('company_settings: only processing-relevant keys produce an event', () => {
|
||||
const counter = auditRowToEvent(
|
||||
auditRow({ table_name: 'company_settings', action: 'UPDATE', old_state: { next_invoice_number: 10 }, new_state: { next_invoice_number: 11 } }),
|
||||
)
|
||||
expect(counter).toBeNull()
|
||||
|
||||
const ev = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'company_settings',
|
||||
action: 'UPDATE',
|
||||
old_state: { moms_period: 'quarterly', accounting_method: 'invoice', invoice_footer_text: 'a' },
|
||||
new_state: { moms_period: 'yearly', accounting_method: 'invoice', invoice_footer_text: 'b' },
|
||||
}),
|
||||
)!
|
||||
expect(ev).toMatchObject({ category: 'installningar', code: 'settings.updated' })
|
||||
expect(ev.details).toEqual(['Momsperiod: Kvartal → Helår'])
|
||||
})
|
||||
|
||||
it('fiscal_periods: lock, close, app-written unlock, closed externally', () => {
|
||||
const lock = auditRowToEvent(auditRow({ table_name: 'fiscal_periods', action: 'LOCK_PERIOD', new_state: { name: 'RÅ 2025' } }))!
|
||||
expect(lock).toMatchObject({ category: 'period', code: 'period.locked', object: 'RÅ 2025' })
|
||||
|
||||
const close = auditRowToEvent(auditRow({ table_name: 'fiscal_periods', action: 'CLOSE_PERIOD', new_state: { name: 'RÅ 2025' } }))!
|
||||
expect(close.code).toBe('period.closed')
|
||||
|
||||
const unlock = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'fiscal_periods',
|
||||
action: 'UPDATE',
|
||||
old_state: { locked_at: '2026-01-01T00:00:00Z' },
|
||||
new_state: { locked_at: null },
|
||||
description: 'Period unlocked: RÅ 2025 (2025-01-01 to 2025-12-31)',
|
||||
}),
|
||||
)!
|
||||
expect(unlock).toMatchObject({ code: 'period.unlocked', object: 'RÅ 2025 (2025-01-01 to 2025-12-31)' })
|
||||
|
||||
const ext = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'fiscal_periods',
|
||||
action: 'UPDATE',
|
||||
old_state: { is_closed: false, closed_at: null, locked_at: null },
|
||||
new_state: { is_closed: true, closed_at: 'x', closed_externally: true, locked_at: 'y' },
|
||||
}),
|
||||
)!
|
||||
expect(ext.code).toBe('period.closed_externally')
|
||||
})
|
||||
|
||||
it('api_keys: created with scopes, revoked, and usage-only updates dropped', () => {
|
||||
const created = auditRowToEvent(
|
||||
auditRow({ table_name: 'api_keys', action: 'INSERT', new_state: { name: 'Zapier', scopes: ['read', 'write'] } }),
|
||||
)!
|
||||
expect(created).toMatchObject({ category: 'atkomst', code: 'api_key.created', object: 'Zapier' })
|
||||
expect(created.details).toEqual(['Behörigheter: read, write'])
|
||||
|
||||
const revoked = auditRowToEvent(
|
||||
auditRow({ table_name: 'api_keys', action: 'UPDATE', old_state: { name: 'Zapier', revoked_at: null }, new_state: { name: 'Zapier', revoked_at: 'now' } }),
|
||||
)!
|
||||
expect(revoked.code).toBe('api_key.revoked')
|
||||
|
||||
const usage = auditRowToEvent(
|
||||
auditRow({ table_name: 'api_keys', action: 'UPDATE', old_state: { name: 'Zapier', request_count: 1 }, new_state: { name: 'Zapier', request_count: 2 } }),
|
||||
)
|
||||
expect(usage).toBeNull()
|
||||
})
|
||||
|
||||
it('global actions land in ovrigt regardless of table; registers are ignored', () => {
|
||||
const sec = auditRowToEvent(
|
||||
auditRow({ table_name: 'webhooks', action: 'SECURITY_EVENT', actor_type: 'system', description: 'Signature mismatch' }),
|
||||
)!
|
||||
expect(sec).toMatchObject({ category: 'ovrigt', code: 'security.event', details: ['Signature mismatch'] })
|
||||
|
||||
expect(auditRowToEvent(auditRow({ table_name: 'supplier_invoices', action: 'INSERT', new_state: { id: 'x' } }))).toBeNull()
|
||||
expect(auditRowToEvent(auditRow({ table_name: 'document_attachments', action: 'INSERT', new_state: { file_name: 'a.pdf' } }))).toBeNull()
|
||||
expect(auditRowToEvent(auditRow({ table_name: 'document_attachments', action: 'DELETE', old_state: { file_name: 'a.pdf' } }))!.code).toBe('document.deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// rattelseEvent
|
||||
// ============================================================
|
||||
|
||||
describe('rattelseEvent', () => {
|
||||
it('describes struck and added lines with account and amount', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
id: 'r1',
|
||||
journal_entry_id: 'entry-1',
|
||||
rattelse_type: 'lines',
|
||||
old_description: null,
|
||||
new_description: null,
|
||||
old_entry_date: null,
|
||||
new_entry_date: null,
|
||||
struck_lines: [{ account_number: '6540', debit_amount: 1200, credit_amount: 0 }],
|
||||
added_lines: [{ account_number: '6550', debit_amount: 1200, credit_amount: 0 }],
|
||||
actor: 'user-2',
|
||||
created_at: '2026-03-11T08:00:00Z',
|
||||
},
|
||||
new Map([['entry-1', baseEntry]]),
|
||||
)
|
||||
expect(ev).toMatchObject({ code: 'journal_entry.corrected_lines', object: 'A12', actor: { user_id: 'user-2' } })
|
||||
expect(ev.details[0]).toContain('6540 D 1')
|
||||
expect(ev.details[1]).toContain('6550 D 1')
|
||||
})
|
||||
|
||||
it('describes metadata changes', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
id: 'r2',
|
||||
journal_entry_id: 'missing',
|
||||
rattelse_type: 'metadata',
|
||||
old_description: 'Hyra',
|
||||
new_description: 'Hyra mars',
|
||||
old_entry_date: '2026-03-01',
|
||||
new_entry_date: '2026-03-01',
|
||||
struck_lines: null,
|
||||
added_lines: null,
|
||||
actor: 'user-2',
|
||||
created_at: '2026-03-11T08:00:00Z',
|
||||
},
|
||||
new Map(),
|
||||
)
|
||||
expect(ev.object).toBeNull()
|
||||
expect(ev.details).toEqual(['Beskrivning: Hyra → Hyra mars'])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// collapse + sort + labels
|
||||
// ============================================================
|
||||
|
||||
describe('collapseBursts', () => {
|
||||
it('collapses a run of same-actor account inserts into one event and keeps short runs', () => {
|
||||
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
|
||||
const burst = Array.from({ length: 12 }, (_, i) =>
|
||||
rawEvent({ occurred_at: new Date(t0 + i * 1000).toISOString(), object: `${1000 + i} Konto ${i}` }),
|
||||
)
|
||||
const other = rawEvent({
|
||||
code: 'settings.updated',
|
||||
category: 'installningar',
|
||||
event: 'Företagsinställningar ändrade',
|
||||
occurred_at: new Date(t0 + 20_000).toISOString(),
|
||||
object: null,
|
||||
})
|
||||
const small = Array.from({ length: 3 }, (_, i) =>
|
||||
rawEvent({ occurred_at: new Date(t0 + 30_000 + i * 1000).toISOString(), object: `20${i}0 Konto` }),
|
||||
)
|
||||
const out = collapseBursts(sortEvents([...burst, other, ...small]))
|
||||
expect(out).toHaveLength(1 + 1 + 3)
|
||||
expect(out[0]).toMatchObject({ code: 'account.created.bulk', event: 'Kontoplan upplagd', object: '12 konton', count: 12 })
|
||||
expect(out[0].details).toEqual(['Konton 1000 till 1011'])
|
||||
expect(out[1].code).toBe('settings.updated')
|
||||
})
|
||||
|
||||
it('splits runs on actor change and on a gap', () => {
|
||||
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
|
||||
const a = Array.from({ length: 10 }, (_, i) => rawEvent({ occurred_at: new Date(t0 + i * 1000).toISOString() }))
|
||||
const b = Array.from({ length: 10 }, (_, i) =>
|
||||
rawEvent({ occurred_at: new Date(t0 + 10_000 + i * 1000).toISOString(), actor: { type: 'api_key', user_id: null, actor_label: 'Sync' } }),
|
||||
)
|
||||
const c = Array.from({ length: 10 }, (_, i) => rawEvent({ occurred_at: new Date(t0 + 600_000 + i * 1000).toISOString() }))
|
||||
const out = collapseBursts(sortEvents([...a, ...b, ...c]))
|
||||
expect(out.map((e) => e.count)).toEqual([10, 10, 10])
|
||||
})
|
||||
|
||||
it('collapses bulk underlag deletions and lists the first file names', () => {
|
||||
const t0 = Date.parse('2026-08-10T12:40:45.000Z')
|
||||
const run = Array.from({ length: 7 }, (_, i) =>
|
||||
rawEvent({
|
||||
code: 'document.deleted',
|
||||
category: 'ovrigt',
|
||||
event: 'Underlag borttaget',
|
||||
occurred_at: new Date(t0 + i * 100).toISOString(),
|
||||
object: `Receipt-${Math.floor(i / 2)}.pdf`,
|
||||
}),
|
||||
)
|
||||
const two = run.slice(0, 2)
|
||||
expect(collapseBursts(two)).toHaveLength(2)
|
||||
const out = collapseBursts(run)
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]).toMatchObject({ code: 'document.deleted.bulk', event: 'Underlag borttagna', object: '7 underlag', category: 'ovrigt', count: 7 })
|
||||
expect(out[0].details).toEqual(['Receipt-0.pdf, Receipt-1.pdf, Receipt-2.pdf, Receipt-3.pdf'])
|
||||
})
|
||||
|
||||
it('summarises which fields a bulk update touched', () => {
|
||||
const t0 = Date.parse('2026-03-10T10:00:00.000Z')
|
||||
const run = Array.from({ length: 10 }, (_, i) =>
|
||||
rawEvent({
|
||||
code: 'account.updated',
|
||||
event: 'Konto ändrat',
|
||||
occurred_at: new Date(t0 + i).toISOString(),
|
||||
details: [i % 2 ? 'Momskod: 25 → 12' : 'Namn: a → b'],
|
||||
}),
|
||||
)
|
||||
const out = collapseBursts(run)
|
||||
expect(out[0].details).toContain('Ändrade fält: Namn, Momskod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatActorLabel', () => {
|
||||
const labels = new Map([['user-1', 'anna@example.se']])
|
||||
it('maps every actor type', () => {
|
||||
expect(formatActorLabel({ type: 'user', user_id: 'user-1', actor_label: null }, labels)).toBe('anna@example.se')
|
||||
expect(formatActorLabel({ type: 'user', user_id: 'deadbeef-0000', actor_label: null }, labels)).toBe('Användare deadbeef')
|
||||
expect(formatActorLabel({ type: 'user', user_id: null, actor_label: null }, labels)).toBe('Okänd användare')
|
||||
expect(formatActorLabel({ type: 'api_key', user_id: 'user-1', actor_label: 'Zapier' }, labels)).toBe('API-nyckel: Zapier')
|
||||
expect(formatActorLabel({ type: 'mcp_oauth', user_id: null, actor_label: null }, labels)).toBe('MCP-anslutning')
|
||||
expect(formatActorLabel({ type: 'agent_chat', user_id: 'user-1', actor_label: null }, labels)).toBe('Assistenten, på uppdrag av anna@example.se')
|
||||
expect(formatActorLabel({ type: 'cron', user_id: null, actor_label: null }, labels)).toBe('Schemalagd körning')
|
||||
expect(formatActorLabel({ type: 'system', user_id: null, actor_label: 'seed' }, labels)).toBe('Systemet: seed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStockholmTimestamp', () => {
|
||||
it('renders Swedish local time', () => {
|
||||
expect(formatStockholmTimestamp('2026-03-10T10:00:00.000Z')).toBe('2026-03-10 11:00:00')
|
||||
expect(formatStockholmTimestamp('2026-07-10T10:00:00.000Z')).toBe('2026-07-10 12:00:00')
|
||||
expect(formatStockholmTimestamp('not a date')).toBe('not a date')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// generateBehandlingshistorik (table-keyed mock client)
|
||||
// ============================================================
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
let mockResults: Record<string, MockResult[]>
|
||||
|
||||
function makeBuilder(table: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'or', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
const consume = (): MockResult => {
|
||||
const queue = mockResults[table]
|
||||
if (!queue || queue.length === 0) return { data: null, error: null }
|
||||
return queue.shift()!
|
||||
}
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => consume())
|
||||
b.single = vi.fn().mockImplementation(async () => consume())
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(consume())
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { from: vi.fn().mockImplementation((table: string) => makeBuilder(table)) } as any
|
||||
}
|
||||
|
||||
const period = { id: 'period-1', name: 'RÅ 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
beforeEach(() => {
|
||||
mockResults = {}
|
||||
})
|
||||
|
||||
describe('generateBehandlingshistorik', () => {
|
||||
it('returns null when the period does not belong to the company', async () => {
|
||||
mockResults = { fiscal_periods: [{ data: null }] }
|
||||
const report = await generateBehandlingshistorik(makeClient(), 'company-1', { periodId: 'nope' })
|
||||
expect(report).toBeNull()
|
||||
})
|
||||
|
||||
it('assembles, sorts and labels events from every source in fiscal-year mode', async () => {
|
||||
const bokslut = { ...baseEntry, id: 'entry-2', voucher_number: 40, entry_date: '2026-12-31', committed_at: '2027-02-15T12:00:00.000Z', source_type: 'year_end', description: 'Bokslut' }
|
||||
mockResults = {
|
||||
fiscal_periods: [{ data: period }],
|
||||
company_settings: [{ data: { company_name: 'Testbolaget AB', org_number: '556000-0001' } }],
|
||||
journal_entries: [{ data: [baseEntry, bokslut, { ...baseEntry, id: 'draft', status: 'draft', committed_at: null, voucher_number: null }] }],
|
||||
audit_log: [
|
||||
// windowed
|
||||
{
|
||||
data: [
|
||||
auditRow({ id: 'a1', table_name: 'company_settings', action: 'UPDATE', created_at: '2026-02-01T08:00:00.000Z', old_state: { moms_period: 'quarterly' }, new_state: { moms_period: 'monthly' } }),
|
||||
auditRow({ id: 'a2', table_name: 'chart_of_accounts', action: 'INSERT', created_at: '2026-02-02T08:00:00.000Z', new_state: { account_number: '6540', account_name: 'IT' } }),
|
||||
],
|
||||
},
|
||||
// record-id union (bokslut storno logged after period end)
|
||||
{
|
||||
data: [
|
||||
auditRow({ id: 'a3', record_id: 'entry-2', action: 'REVERSE', created_at: '2027-03-01T09:00:00.000Z', old_state: { voucher_series: 'A', voucher_number: 40, status: 'posted' }, new_state: { voucher_series: 'A', voucher_number: 40, status: 'reversed' }, actor_type: 'api_key', actor_label: 'Revisorn' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
journal_entry_rattelse_log: [{ data: [] }, { data: [] }],
|
||||
company_migration_resets: [{ data: [] }, { data: [] }],
|
||||
sie_imports: [
|
||||
{
|
||||
data: [
|
||||
{ id: 's1', user_id: 'user-3', filename: 'bokio.se', sie_type: 4, fiscal_year_start: '2025-01-01', fiscal_year_end: '2025-12-31', accounts_count: 120, transactions_count: 900, status: 'completed', error_message: null, imported_at: '2026-01-05T10:00:00.000Z', created_at: '2026-01-05T09:55:00.000Z', replaced_at: null },
|
||||
{ id: 's0', user_id: 'user-3', filename: 'old.se', sie_type: 4, fiscal_year_start: null, fiscal_year_end: null, accounts_count: null, transactions_count: null, status: 'completed', error_message: null, imported_at: '2025-06-01T10:00:00.000Z', created_at: '2025-06-01T10:00:00.000Z', replaced_at: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
bank_file_imports: [
|
||||
{ data: [{ id: 'b1', user_id: 'user-1', filename: 'seb.csv', file_format: 'seb', transaction_count: 40, imported_count: 38, duplicate_count: 2, status: 'completed', error_message: null, date_from: '2026-01-01', date_to: '2026-01-31', created_at: '2026-02-03T08:00:00.000Z' }] },
|
||||
],
|
||||
}
|
||||
const resolve = vi.fn().mockResolvedValue(new Map([['user-1', 'anna@example.se'], ['user-3', 'kim@example.se']]))
|
||||
|
||||
const report = await generateBehandlingshistorik(makeClient(), 'company-1', { periodId: 'period-1' }, {
|
||||
resolveUserLabels: resolve,
|
||||
appVersion: 'abc1234',
|
||||
now: new Date('2026-08-21T12:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(report).not.toBeNull()
|
||||
expect(report!.mode).toBe('fiscal_year')
|
||||
expect(report!.company).toEqual({ name: 'Testbolaget AB', org_number: '556000-0001' })
|
||||
expect(report!.app_version).toBe('abc1234')
|
||||
expect(report!.range).toEqual({ from: '2026-01-01', to: '2026-12-31' })
|
||||
// Both booked entries (bokslut entry committed after period end included), no draft.
|
||||
const codes = report!.events.map((e) => e.code)
|
||||
expect(codes).toEqual([
|
||||
'sie_import.completed',
|
||||
'settings.updated',
|
||||
'account.created',
|
||||
'bank_file_import.completed',
|
||||
'journal_entry.committed',
|
||||
'journal_entry.committed',
|
||||
'journal_entry.reversed',
|
||||
])
|
||||
expect(report!.total_events).toBe(7)
|
||||
expect(report!.by_category).toMatchObject({ verifikation: 3, kontoplan: 1, installningar: 1, import: 2 })
|
||||
// Labels resolved through the injected resolver, machine actors kept.
|
||||
expect(resolve).toHaveBeenCalledWith(expect.arrayContaining(['user-1', 'user-3']))
|
||||
const byCode = Object.fromEntries(report!.events.map((e) => [e.id, e]))
|
||||
expect(byCode['entry:entry-1'].actor.label).toBe('anna@example.se')
|
||||
expect(byCode['sie:s1'].actor.label).toBe('kim@example.se')
|
||||
expect(byCode['audit:a3'].actor.label).toBe('API-nyckel: Revisorn')
|
||||
// The sie import outside the window is not included.
|
||||
expect(byCode['sie:s0']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('date-range mode keeps only what was registered inside the window and filters categories', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [{ data: period }],
|
||||
company_settings: [{ data: { company_name: 'T', org_number: null } }],
|
||||
journal_entries: [{ data: [baseEntry, { ...baseEntry, id: 'entry-2', voucher_number: 13, committed_at: '2026-05-02T10:00:00.000Z' }] }],
|
||||
audit_log: [
|
||||
{ data: [auditRow({ id: 'a1', table_name: 'chart_of_accounts', action: 'DELETE', created_at: '2026-03-15T08:00:00.000Z', old_state: { account_number: '6540', account_name: 'IT' } })] },
|
||||
],
|
||||
journal_entry_rattelse_log: [{ data: [] }],
|
||||
company_migration_resets: [{ data: [] }, { data: [] }],
|
||||
sie_imports: [{ data: [] }],
|
||||
bank_file_imports: [{ data: [] }],
|
||||
}
|
||||
const client = makeClient()
|
||||
const report = await generateBehandlingshistorik(client, 'company-1', {
|
||||
periodId: 'period-1',
|
||||
fromDate: '2026-03-01',
|
||||
toDate: '2026-03-31',
|
||||
categories: ['verifikation'],
|
||||
})
|
||||
expect(report!.mode).toBe('date_range')
|
||||
expect(report!.events.map((e) => e.id)).toEqual(['entry:entry-1'])
|
||||
expect(report!.by_category.kontoplan).toBe(0)
|
||||
// No record-id union in date-range mode: audit_log queried once.
|
||||
expect(client.from.mock.calls.filter((c: string[]) => c[0] === 'audit_log')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// export
|
||||
// ============================================================
|
||||
|
||||
describe('buildBehandlingshistorikExport', () => {
|
||||
const report = {
|
||||
company: { name: 'Testbolaget AB', org_number: '556000-0001' },
|
||||
period: { id: 'p', name: 'RÅ 2026', start: '2026-01-01', end: '2026-12-31' },
|
||||
range: { from: '2026-01-01', to: '2026-12-31' },
|
||||
mode: 'fiscal_year' as const,
|
||||
generated_at: '2026-08-21T12:00:00.000Z',
|
||||
app_version: 'abc1234',
|
||||
total_events: 1,
|
||||
by_category: { verifikation: 1, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 },
|
||||
events: [
|
||||
{
|
||||
id: 'entry:1',
|
||||
occurred_at: '2026-03-10T09:30:00.000Z',
|
||||
category: 'verifikation' as const,
|
||||
code: 'journal_entry.committed',
|
||||
event: 'Verifikation bokförd',
|
||||
object: 'A12',
|
||||
actor: { type: 'user' as const, user_id: 'u', label: 'anna@example.se' },
|
||||
details: ['Datum: 2026-03-09', 'Källa: Manuell'],
|
||||
source: 'journal_entries' as const,
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('csv carries a BOM, the header row and Swedish local time', () => {
|
||||
const out = buildBehandlingshistorikExport(report, 'csv')
|
||||
expect(out.contentType).toBe('text/csv; charset=utf-8')
|
||||
expect(out.filename).toBe('behandlingshistorik-testbolaget-ab-20261231.csv')
|
||||
const text = out.buffer.toString('utf-8')
|
||||
expect(text.charCodeAt(0)).toBe(0xfeff)
|
||||
// Exactly one BOM: SheetJS adds its own for csv, we must not double it.
|
||||
expect(text.charCodeAt(1)).not.toBe(0xfeff)
|
||||
expect(text.startsWith('Tidpunkt,Kategori,Händelse,Objekt,Utförd av,Detaljer,Kod,Antal')).toBe(true)
|
||||
expect(text).toContain('2026-03-10 10:30:00')
|
||||
expect(text).toContain('Datum: 2026-03-09 | Källa: Manuell')
|
||||
})
|
||||
|
||||
it('xlsx is a non-empty workbook with the xlsx mime type', () => {
|
||||
const out = buildBehandlingshistorikExport(report, 'xlsx')
|
||||
expect(out.contentType).toContain('spreadsheetml')
|
||||
expect(out.filename.endsWith('.xlsx')).toBe(true)
|
||||
expect(out.buffer.length).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Behandlingshistorik: shared types and constants.
|
||||
*
|
||||
* Kept separate from the generator so client components can import the
|
||||
* shapes without pulling the xlsx builder into the browser bundle.
|
||||
*/
|
||||
|
||||
export const BEHANDLINGSHISTORIK_CATEGORIES = [
|
||||
'verifikation',
|
||||
'kontoplan',
|
||||
'installningar',
|
||||
'period',
|
||||
'import',
|
||||
'atkomst',
|
||||
'ovrigt',
|
||||
] as const
|
||||
|
||||
export type BehandlingshistorikCategory = (typeof BEHANDLINGSHISTORIK_CATEGORIES)[number]
|
||||
|
||||
export type BehandlingshistorikActorType =
|
||||
| 'user'
|
||||
| 'api_key'
|
||||
| 'mcp_oauth'
|
||||
| 'cron'
|
||||
| 'agent_chat'
|
||||
| 'system'
|
||||
|
||||
export type BehandlingshistorikSource =
|
||||
| 'journal_entries'
|
||||
| 'audit_log'
|
||||
| 'rattelse_log'
|
||||
| 'migration_reset'
|
||||
| 'sie_import'
|
||||
| 'bank_file_import'
|
||||
|
||||
export interface BehandlingshistorikActor {
|
||||
type: BehandlingshistorikActorType
|
||||
user_id: string | null
|
||||
/** Human-readable: e-mail for users, key name for API keys, "Systemet", ... */
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BehandlingshistorikEvent {
|
||||
/** Stable per source row, e.g. `audit:<uuid>`, `entry:<uuid>`. */
|
||||
id: string
|
||||
/** Registreringstidpunkt, ISO 8601 UTC. */
|
||||
occurred_at: string
|
||||
category: BehandlingshistorikCategory
|
||||
/** Stable machine code, e.g. `journal_entry.committed`. */
|
||||
code: string
|
||||
/** Swedish event label (räkenskapsinformation: stays Swedish in both locales). */
|
||||
event: string
|
||||
/** What the event concerns: voucher label, account, period name, file name. */
|
||||
object: string | null
|
||||
actor: BehandlingshistorikActor
|
||||
/** Human-readable detail lines (field diffs, counts, reasons). */
|
||||
details: string[]
|
||||
source: BehandlingshistorikSource
|
||||
/** Number of underlying rows this event summarises (burst collapse). */
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface BehandlingshistorikReport {
|
||||
company: { name: string; org_number: string | null }
|
||||
period: { id: string; name: string; start: string; end: string }
|
||||
/** Effective window (ISO dates, inclusive). */
|
||||
range: { from: string; to: string }
|
||||
mode: 'fiscal_year' | 'date_range'
|
||||
generated_at: string
|
||||
/** Running software version at generation time (BFNAR 2013:2 p. 9.16 second paragraph). */
|
||||
app_version: string | null
|
||||
total_events: number
|
||||
by_category: Record<BehandlingshistorikCategory, number>
|
||||
events: BehandlingshistorikEvent[]
|
||||
}
|
||||
|
||||
/** Swedish category labels for exports and the statutory document. */
|
||||
export const BEHANDLINGSHISTORIK_CATEGORY_LABELS: Record<BehandlingshistorikCategory, string> = {
|
||||
verifikation: 'Verifikationer',
|
||||
kontoplan: 'Kontoplan',
|
||||
installningar: 'Inställningar',
|
||||
period: 'Räkenskapsår',
|
||||
import: 'Import',
|
||||
atkomst: 'Åtkomst',
|
||||
ovrigt: 'Övrigt',
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user